DirectoryPage.tsx 47 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584
  1. import {
  2. useCallback,
  3. useEffect,
  4. useMemo,
  5. useRef,
  6. useState,
  7. } from "react";
  8. import {
  9. Building2,
  10. ChevronDown,
  11. ChevronRight,
  12. Edit2,
  13. Key,
  14. Lock,
  15. Network,
  16. Plus,
  17. RefreshCw,
  18. Shield,
  19. Trash2,
  20. Users,
  21. X,
  22. } from "lucide-react";
  23. import {
  24. EnabledStatus,
  25. RoleCode,
  26. SecurityLevel,
  27. type EnabledStatus as EnabledStatusCode,
  28. type RoleCode as RoleCodeValue,
  29. type SecurityLevel as SecurityLevelValue,
  30. } from "../../shared/api/enums";
  31. import { useConfirm } from "../../shared/ui/ConfirmDialog";
  32. import { useDictionaries } from "../dictionaries/dictionaryContext";
  33. import type { UserSummary } from "../auth/authTypes";
  34. import {
  35. flattenOrganizations,
  36. useOrganizationTree,
  37. useUserDirectory,
  38. } from "./directoryState";
  39. import {
  40. organizationApi,
  41. type OrganizationApi,
  42. } from "./organizationApi";
  43. import { userApi, type UserApi } from "./userApi";
  44. import { roleApi, type RoleApi } from "./roleApi";
  45. import type { RoleSummary } from "./roleTypes";
  46. import { RoleMaintenancePanel } from "./RoleMaintenancePanel";
  47. import { IpEntryMaintenancePanel } from "./IpEntryMaintenancePanel";
  48. import { OrgIpAssignmentModal } from "./OrgIpAssignmentModal";
  49. import type {
  50. OrganizationCreateRequest,
  51. OrganizationNode,
  52. OrganizationUpdateRequest,
  53. UserCreateRequest,
  54. UserQuery,
  55. UserResetPasswordRequest,
  56. UserUpdateRequest,
  57. } from "./directoryTypes";
  58. import {
  59. B,
  60. DANGER,
  61. MAX_TREE_DEPTH,
  62. N,
  63. PBG,
  64. W,
  65. type ApiResultState,
  66. describeError,
  67. initialResult,
  68. LabeledInput,
  69. LabeledNumberInput,
  70. type OptionEntry,
  71. LabeledSelect,
  72. ModalFooter,
  73. ModalShell,
  74. roleOptions,
  75. securityLevelOptions,
  76. smallButton,
  77. statusOptions,
  78. } from "./directoryUi";
  79. type DirectoryTab =
  80. | "organizations"
  81. | "users"
  82. | "roles"
  83. | "ipEntries";
  84. // ===== 主页面 =====
  85. export function DirectoryPage() {
  86. const [tab, setTab] = useState<DirectoryTab>("organizations");
  87. const dictionaries = useDictionaries();
  88. const tabLabel = (key: DirectoryTab) =>
  89. key === "organizations"
  90. ? dictionaries.label("allowedModules", "ORGANIZATION_USER_MANAGEMENT") ||
  91. "机构维护"
  92. : key === "users"
  93. ? "用户维护"
  94. : key === "roles"
  95. ? "角色管理"
  96. : "IP地址列表";
  97. return (
  98. <div
  99. className="w-full h-full flex flex-col overflow-hidden"
  100. style={{ background: PBG }}
  101. >
  102. <div
  103. className="flex-shrink-0 flex items-center gap-1 px-6 py-2 border-b"
  104. style={{ background: W, borderColor: "rgba(49,145,235,0.2)" }}
  105. >
  106. <button
  107. type="button"
  108. onClick={() => setTab("organizations")}
  109. className="flex items-center gap-1.5 px-3 py-1.5 text-base font-black rounded-sm transition-colors"
  110. style={{
  111. color: tab === "organizations" ? W : N,
  112. background: tab === "organizations" ? B : "transparent",
  113. }}
  114. >
  115. <Building2 size={14} />
  116. {tabLabel("organizations")}
  117. </button>
  118. <button
  119. type="button"
  120. onClick={() => setTab("users")}
  121. className="flex items-center gap-1.5 px-3 py-1.5 text-base font-black rounded-sm transition-colors"
  122. style={{
  123. color: tab === "users" ? W : N,
  124. background: tab === "users" ? B : "transparent",
  125. }}
  126. >
  127. <Users size={14} />
  128. {tabLabel("users")}
  129. </button>
  130. <button
  131. type="button"
  132. onClick={() => setTab("roles")}
  133. className="flex items-center gap-1.5 px-3 py-1.5 text-base font-black rounded-sm transition-colors"
  134. style={{
  135. color: tab === "roles" ? W : N,
  136. background: tab === "roles" ? B : "transparent",
  137. }}
  138. >
  139. <Shield size={14} />
  140. {tabLabel("roles")}
  141. </button>
  142. <button
  143. type="button"
  144. onClick={() => setTab("ipEntries")}
  145. className="flex items-center gap-1.5 px-3 py-1.5 text-base font-black rounded-sm transition-colors"
  146. style={{
  147. color: tab === "ipEntries" ? W : N,
  148. background: tab === "ipEntries" ? B : "transparent",
  149. }}
  150. >
  151. <Network size={14} />
  152. {tabLabel("ipEntries")}
  153. </button>
  154. </div>
  155. <div className="flex-1 min-h-0 overflow-hidden">
  156. {tab === "organizations" ? (
  157. <OrganizationMaintenancePanel />
  158. ) : tab === "users" ? (
  159. <UserMaintenancePanel />
  160. ) : tab === "roles" ? (
  161. <RoleMaintenancePanel />
  162. ) : (
  163. <IpEntryMaintenancePanel />
  164. )}
  165. </div>
  166. </div>
  167. );
  168. }
  169. // ===== 机构维护面板 =====
  170. interface OrganizationForm {
  171. orgCode: string;
  172. orgName: string;
  173. parentId: string | null;
  174. sortNo: number;
  175. status: EnabledStatusCode;
  176. }
  177. interface EditState {
  178. mode: "create" | "edit" | null;
  179. target: OrganizationNode | null;
  180. form: OrganizationForm | null;
  181. rowVersion: number;
  182. }
  183. function buildInitialForm(
  184. mode: "create" | "edit",
  185. target: OrganizationNode | null,
  186. defaultParentId: string | null,
  187. ): OrganizationForm {
  188. if (mode === "edit" && target) {
  189. return {
  190. orgCode: target.orgCode,
  191. orgName: target.orgName,
  192. parentId: target.parentId,
  193. sortNo: target.sortNo,
  194. status: target.status,
  195. };
  196. }
  197. return {
  198. orgCode: "",
  199. orgName: "",
  200. parentId: defaultParentId,
  201. sortNo: 0,
  202. status: EnabledStatus.ENABLED,
  203. };
  204. }
  205. function OrganizationMaintenancePanel({
  206. api = organizationApi,
  207. }: {
  208. api?: OrganizationApi;
  209. }) {
  210. const dictionaries = useDictionaries();
  211. const { confirm: confirmDialog, dialog: confirmDialogNode } = useConfirm();
  212. const [keyword, setKeyword] = useState("");
  213. const [retryVersion, setRetryVersion] = useState(0);
  214. const { tree, status, error } = useOrganizationTree(
  215. keyword,
  216. retryVersion,
  217. api,
  218. );
  219. const [expanded, setExpanded] = useState<Set<string>>(() => new Set());
  220. const [selectedId, setSelectedId] = useState<string | null>(null);
  221. const [ipAssignTarget, setIpAssignTarget] = useState<OrganizationNode | null>(
  222. null,
  223. );
  224. const [editState, setEditState] = useState<EditState>({
  225. mode: null,
  226. target: null,
  227. form: null,
  228. rowVersion: 0,
  229. });
  230. const [mutation, setMutation] = useState<ApiResultState>(initialResult);
  231. // 初次加载自动展开所有节点(用 useRef 避免依赖 expanded.size 反复触发)
  232. const initialExpandDone = useRef(false);
  233. useEffect(() => {
  234. if (
  235. !initialExpandDone.current
  236. && status === "success"
  237. && tree.length > 0
  238. ) {
  239. initialExpandDone.current = true;
  240. setExpanded(new Set(flattenOrganizations(tree).map((n) => n.id)));
  241. }
  242. }, [status, tree]);
  243. const flatNodes = useMemo(() => flattenOrganizations(tree), [tree]);
  244. const nodeMap = useMemo(() => {
  245. const map = new Map<string, OrganizationNode>();
  246. flatNodes.forEach((node) => map.set(node.id, node));
  247. return map;
  248. }, [flatNodes]);
  249. const selectedNode = selectedId ? nodeMap.get(selectedId) ?? null : null;
  250. const refresh = useCallback(() => {
  251. setRetryVersion((v) => v + 1);
  252. }, []);
  253. const toggleExpand = (id: string) => {
  254. setExpanded((current) => {
  255. const next = new Set(current);
  256. if (next.has(id)) next.delete(id);
  257. else next.add(id);
  258. return next;
  259. });
  260. };
  261. const openCreate = (parent: OrganizationNode | null) => {
  262. setEditState({
  263. mode: "create",
  264. target: null,
  265. form: buildInitialForm("create", null, parent?.id ?? null),
  266. rowVersion: 0,
  267. });
  268. setMutation(initialResult);
  269. };
  270. const openEdit = (node: OrganizationNode) => {
  271. setEditState({
  272. mode: "edit",
  273. target: node,
  274. form: buildInitialForm("edit", node, null),
  275. rowVersion: node.rowVersion,
  276. });
  277. setMutation(initialResult);
  278. };
  279. const closeEditor = () => {
  280. setEditState({
  281. mode: null,
  282. target: null,
  283. form: null,
  284. rowVersion: 0,
  285. });
  286. setMutation(initialResult);
  287. };
  288. const handleSubmit = async () => {
  289. if (!editState.form || !editState.mode) return;
  290. const form = editState.form;
  291. if (!form.orgCode.trim() || !form.orgName.trim()) {
  292. setMutation({ submitting: false, error: "编码与名称均不能为空" });
  293. return;
  294. }
  295. setMutation({ submitting: true, error: null });
  296. try {
  297. if (editState.mode === "create") {
  298. const payload: OrganizationCreateRequest = {
  299. orgCode: form.orgCode.trim(),
  300. orgName: form.orgName.trim(),
  301. parentId: form.parentId,
  302. sortNo: form.sortNo,
  303. status: form.status,
  304. };
  305. const created = await api.create(payload);
  306. setExpanded((current) => {
  307. const next = new Set(current);
  308. if (form.parentId) next.add(form.parentId);
  309. next.add(created.id);
  310. return next;
  311. });
  312. setSelectedId(created.id);
  313. } else if (editState.target) {
  314. const payload: OrganizationUpdateRequest = {
  315. orgCode: form.orgCode.trim(),
  316. orgName: form.orgName.trim(),
  317. parentId: form.parentId,
  318. sortNo: form.sortNo,
  319. status: form.status,
  320. rowVersion: editState.rowVersion,
  321. };
  322. await api.update(editState.target.id, payload);
  323. }
  324. closeEditor();
  325. refresh();
  326. } catch (error) {
  327. setMutation({ submitting: false, error: describeError(error) });
  328. }
  329. };
  330. const handleDelete = async (node: OrganizationNode) => {
  331. if (!(await confirmDialog({ message: `确认删除机构「${node.orgName}」?`, danger: true }))) return;
  332. setMutation({ submitting: true, error: null });
  333. try {
  334. await api.delete(node.id, node.rowVersion);
  335. if (selectedId === node.id) setSelectedId(null);
  336. setMutation(initialResult);
  337. refresh();
  338. } catch (error) {
  339. setMutation({ submitting: false, error: describeError(error) });
  340. }
  341. };
  342. return (
  343. <div className="w-full h-full flex flex-col overflow-hidden">
  344. <div
  345. className="flex-shrink-0 flex items-center justify-between gap-3 px-6 py-3 border-b"
  346. style={{ background: W, borderColor: "rgba(49,145,235,0.15)" }}
  347. >
  348. <div className="flex items-center gap-2 flex-1 max-w-md">
  349. <input
  350. value={keyword}
  351. onChange={(e) => setKeyword(e.target.value)}
  352. placeholder="按机构名称或编码筛选"
  353. className="flex-1 px-3 py-1.5 text-base border rounded-sm outline-none"
  354. style={{ borderColor: "rgba(49,145,235,0.4)", color: N }}
  355. />
  356. {keyword && (
  357. <button
  358. type="button"
  359. onClick={() => setKeyword("")}
  360. className="text-base font-bold opacity-60 hover:opacity-100"
  361. style={{ color: N }}
  362. >
  363. <X size={14} />
  364. </button>
  365. )}
  366. </div>
  367. <div className="flex items-center gap-1.5">
  368. {smallButton(false, {
  369. label: "刷新",
  370. icon: <RefreshCw size={12} />,
  371. onClick: refresh,
  372. })}
  373. {smallButton(true, {
  374. label: "新建顶层机构",
  375. icon: <Plus size={12} />,
  376. onClick: () => openCreate(null),
  377. })}
  378. </div>
  379. </div>
  380. <div
  381. className="flex-1 min-h-0 overflow-auto px-6 py-4"
  382. style={{ background: PBG }}
  383. >
  384. {status === "loading" && (
  385. <div
  386. className="text-base font-bold text-center py-8"
  387. style={{ color: N }}
  388. >
  389. 正在加载机构树…
  390. </div>
  391. )}
  392. {status === "error" && (
  393. <div
  394. className="text-base font-bold text-center py-8"
  395. style={{ color: DANGER }}
  396. >
  397. {error?.message ?? "加载失败"}
  398. </div>
  399. )}
  400. {status !== "loading" && status !== "error" && tree.length === 0 && (
  401. <div
  402. className="text-base font-bold text-center py-8"
  403. style={{ color: N }}
  404. >
  405. 暂无机构数据,点击右上角"新建顶层机构"开始维护。
  406. </div>
  407. )}
  408. {tree.length > 0 && (
  409. <div
  410. className="inline-block min-w-full rounded-sm border"
  411. style={{ background: W, borderColor: "rgba(49,145,235,0.2)" }}
  412. >
  413. {tree.map((node) => (
  414. <OrganizationTreeRow
  415. key={node.id}
  416. node={node}
  417. depth={0}
  418. expanded={expanded}
  419. onToggle={toggleExpand}
  420. selectedId={selectedId}
  421. onSelect={setSelectedId}
  422. onAddChild={(parent) => openCreate(parent)}
  423. onAssignIp={setIpAssignTarget}
  424. onEdit={openEdit}
  425. onDelete={handleDelete}
  426. />
  427. ))}
  428. </div>
  429. )}
  430. </div>
  431. {mutation.error && (
  432. <div
  433. className="flex-shrink-0 px-6 py-2 text-base font-bold border-t"
  434. style={{
  435. color: DANGER,
  436. background: "rgba(192,57,43,0.05)",
  437. borderColor: "rgba(192,57,43,0.2)",
  438. }}
  439. >
  440. {mutation.error}
  441. </div>
  442. )}
  443. {ipAssignTarget && (
  444. <OrgIpAssignmentModal
  445. org={ipAssignTarget}
  446. onClose={() => setIpAssignTarget(null)}
  447. onSaved={() => {
  448. setIpAssignTarget(null);
  449. refresh();
  450. }}
  451. />
  452. )}
  453. {editState.form && editState.mode && (
  454. <OrganizationEditorModal
  455. mode={editState.mode}
  456. target={editState.target}
  457. form={editState.form}
  458. allNodes={flatNodes}
  459. onChange={(patch) =>
  460. setEditState((current) =>
  461. current.form
  462. ? {
  463. ...current,
  464. form: { ...current.form, ...patch },
  465. }
  466. : current,
  467. )
  468. }
  469. onClose={closeEditor}
  470. onSubmit={handleSubmit}
  471. submitting={mutation.submitting}
  472. statusLabel={(code) => dictionaries.label("enabledStatuses", code)}
  473. />
  474. )}
  475. {confirmDialogNode}
  476. </div>
  477. );
  478. }
  479. function OrganizationTreeRow({
  480. node,
  481. depth,
  482. expanded,
  483. onToggle,
  484. selectedId,
  485. onSelect,
  486. onAddChild,
  487. onAssignIp,
  488. onEdit,
  489. onDelete,
  490. }: {
  491. node: OrganizationNode;
  492. depth: number;
  493. expanded: ReadonlySet<string>;
  494. onToggle: (id: string) => void;
  495. selectedId: string | null;
  496. onSelect: (id: string) => void;
  497. onAddChild: (parent: OrganizationNode) => void;
  498. onAssignIp: (node: OrganizationNode) => void;
  499. onEdit: (node: OrganizationNode) => void;
  500. onDelete: (node: OrganizationNode) => void;
  501. }) {
  502. const dictionaries = useDictionaries();
  503. // 防御性:避免极端深度时栈溢出(后端 _descendants 已做循环检测,此处为前端二次保护)
  504. if (depth >= MAX_TREE_DEPTH) return null;
  505. const isOpen = expanded.has(node.id);
  506. const isSelected = selectedId === node.id;
  507. const hasChildren = node.children.length > 0;
  508. return (
  509. <>
  510. <div
  511. 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"}`}
  512. style={{
  513. paddingLeft: 12 + depth * 18,
  514. borderColor: "rgba(49,145,235,0.1)",
  515. }}
  516. onClick={() => onSelect(node.id)}
  517. >
  518. {hasChildren ? (
  519. <button
  520. type="button"
  521. onClick={(e) => {
  522. e.stopPropagation();
  523. onToggle(node.id);
  524. }}
  525. className="opacity-70 hover:opacity-100"
  526. style={{ color: N }}
  527. >
  528. {isOpen ? <ChevronDown size={13} /> : <ChevronRight size={13} />}
  529. </button>
  530. ) : (
  531. <span className="inline-block w-[13px]" />
  532. )}
  533. <span className="text-base font-black" style={{ color: N }}>
  534. {node.orgName}
  535. </span>
  536. <span
  537. className="text-base font-bold opacity-60"
  538. style={{ color: N }}
  539. >
  540. ({node.orgCode})
  541. </span>
  542. <span
  543. className="text-base font-bold px-1.5 py-0.5 rounded-sm"
  544. style={{
  545. color: node.status === EnabledStatus.ENABLED ? N : DANGER,
  546. background:
  547. node.status === EnabledStatus.ENABLED
  548. ? "rgba(49,145,235,0.1)"
  549. : "rgba(192,57,43,0.08)",
  550. }}
  551. >
  552. {dictionaries.label("enabledStatuses", node.status)}
  553. </span>
  554. <span
  555. className="text-base font-bold opacity-50 flex-1 truncate ml-2"
  556. style={{ color: N }}
  557. >
  558. {node.orgPath}
  559. </span>
  560. <div className="flex items-center gap-1.5" onClick={(e) => e.stopPropagation()}>
  561. {smallButton(false, {
  562. label: "新增下级",
  563. icon: <Plus size={12} />,
  564. onClick: () => onAddChild(node),
  565. })}
  566. {smallButton(false, {
  567. label: "分配IP",
  568. icon: <Network size={12} />,
  569. onClick: () => onAssignIp(node),
  570. })}
  571. {smallButton(false, {
  572. label: "编辑",
  573. icon: <Edit2 size={12} />,
  574. onClick: () => onEdit(node),
  575. })}
  576. {smallButton(false, {
  577. label: "删除",
  578. icon: <Trash2 size={12} />,
  579. onClick: () => onDelete(node),
  580. danger: true,
  581. })}
  582. </div>
  583. </div>
  584. {isOpen &&
  585. node.children.map((child) => (
  586. <OrganizationTreeRow
  587. key={child.id}
  588. node={child}
  589. depth={depth + 1}
  590. expanded={expanded}
  591. onToggle={onToggle}
  592. selectedId={selectedId}
  593. onSelect={onSelect}
  594. onAddChild={onAddChild}
  595. onAssignIp={onAssignIp}
  596. onEdit={onEdit}
  597. onDelete={onDelete}
  598. />
  599. ))}
  600. </>
  601. );
  602. }
  603. function OrganizationEditorModal({
  604. mode,
  605. target,
  606. form,
  607. allNodes,
  608. onChange,
  609. onClose,
  610. onSubmit,
  611. submitting,
  612. statusLabel,
  613. }: {
  614. mode: "create" | "edit";
  615. target: OrganizationNode | null;
  616. form: OrganizationForm;
  617. allNodes: OrganizationNode[];
  618. onChange: (patch: Partial<OrganizationForm>) => void;
  619. onClose: () => void;
  620. onSubmit: () => void;
  621. submitting: boolean;
  622. statusLabel: (code: EnabledStatusCode) => string;
  623. }) {
  624. const dictionaries = useDictionaries();
  625. const title = mode === "create" ? "新建机构" : `编辑机构:${target?.orgName ?? ""}`;
  626. const isEdit = mode === "edit";
  627. const parentOptions = useMemo(() => {
  628. const result: OrganizationNode[] = [];
  629. const blockedIds = new Set<string>();
  630. if (mode === "edit" && target) {
  631. blockedIds.add(target.id);
  632. const collectDescendants = (root: OrganizationNode) => {
  633. root.children.forEach((child) => {
  634. blockedIds.add(child.id);
  635. collectDescendants(child);
  636. });
  637. };
  638. collectDescendants(target);
  639. }
  640. allNodes.forEach((node) => {
  641. if (!blockedIds.has(node.id)) result.push(node);
  642. });
  643. return result;
  644. }, [allNodes, mode, target]);
  645. const parentSelectOptions = useMemo<OptionEntry<string>[]>(
  646. () =>
  647. parentOptions.map((node) => ({
  648. value: node.id,
  649. label: `${node.orgPath} (${node.orgCode})`,
  650. })),
  651. [parentOptions],
  652. );
  653. const statusSelectOptions = useMemo<OptionEntry<EnabledStatusCode>[]>(
  654. () => statusOptions().map((code) => ({ value: code, label: statusLabel(code) })),
  655. [statusLabel],
  656. );
  657. return (
  658. <ModalShell title={title} onClose={onClose}>
  659. <div className="space-y-3">
  660. <LabeledInput
  661. label="机构编码"
  662. value={form.orgCode}
  663. onChange={(v) => onChange({ orgCode: v })}
  664. placeholder="如:ORG_HQ"
  665. />
  666. <LabeledInput
  667. label="机构名称"
  668. value={form.orgName}
  669. onChange={(v) => onChange({ orgName: v })}
  670. />
  671. <LabeledSelect<string>
  672. label="上级机构"
  673. value={form.parentId ?? ""}
  674. onChange={(v) => onChange({ parentId: v || null })}
  675. options={[
  676. { value: "", label: "— 顶层机构 —" },
  677. ...parentSelectOptions,
  678. ]}
  679. />
  680. <LabeledNumberInput
  681. label="排序号"
  682. value={form.sortNo}
  683. onChange={(v) => onChange({ sortNo: v })}
  684. />
  685. <LabeledSelect<EnabledStatusCode>
  686. label="状态"
  687. value={form.status}
  688. onChange={(v) => onChange({ status: v })}
  689. options={statusSelectOptions}
  690. />
  691. </div>
  692. <ModalFooter
  693. onClose={onClose}
  694. onSubmit={onSubmit}
  695. submitting={submitting}
  696. submitLabel={mode === "create" ? "创建" : "保存"}
  697. secondaryTooltip={dictionaries.label(
  698. "auditActions",
  699. mode === "create" ? "CREATE_ORGANIZATION" : "EDIT_ORGANIZATION",
  700. )}
  701. />
  702. </ModalShell>
  703. );
  704. }
  705. // ===== 用户维护面板 =====
  706. interface UserForm {
  707. username: string;
  708. realName: string;
  709. initialPassword: string;
  710. organizationId: string | null;
  711. roleCode: RoleCodeValue;
  712. roleId: string | null;
  713. securityLevel: SecurityLevelValue;
  714. status: EnabledStatusCode;
  715. }
  716. interface UserEditState {
  717. mode: "create" | "edit" | null;
  718. target: UserSummary | null;
  719. form: UserForm | null;
  720. rowVersion: number;
  721. }
  722. interface ResetState {
  723. target: UserSummary | null;
  724. newPassword: string;
  725. }
  726. function buildInitialUserForm(
  727. mode: "create" | "edit",
  728. target: UserSummary | null,
  729. defaultOrgId: string | null,
  730. ): UserForm {
  731. if (mode === "edit" && target) {
  732. return {
  733. username: target.username,
  734. realName: target.realName,
  735. initialPassword: "",
  736. organizationId: target.organizationId,
  737. roleCode: target.roleCode,
  738. roleId: target.roleId,
  739. securityLevel: target.securityLevel,
  740. status: target.status,
  741. };
  742. }
  743. return {
  744. username: "",
  745. realName: "",
  746. initialPassword: "",
  747. organizationId: defaultOrgId,
  748. roleCode: RoleCode.USER,
  749. roleId: null,
  750. securityLevel: SecurityLevel.INTERNAL,
  751. status: EnabledStatus.ENABLED,
  752. };
  753. }
  754. function UserMaintenancePanel({
  755. userApiImpl = userApi,
  756. orgApiImpl = organizationApi,
  757. roleApiImpl = roleApi,
  758. }: {
  759. userApiImpl?: UserApi;
  760. orgApiImpl?: OrganizationApi;
  761. roleApiImpl?: RoleApi;
  762. }) {
  763. const dictionaries = useDictionaries();
  764. const { confirm: confirmDialog, dialog: confirmDialogNode } = useConfirm();
  765. const [orgKeyword, setOrgKeyword] = useState("");
  766. const [orgRetry, setOrgRetry] = useState(0);
  767. // 功能角色下拉数据(仅 roleCode=USER 时可选)
  768. const [roles, setRoles] = useState<RoleSummary[]>([]);
  769. useEffect(() => {
  770. let cancelled = false;
  771. roleApiImpl
  772. .getRoles({ pageSize: 100 })
  773. .then((page) => {
  774. if (!cancelled) setRoles(page.items);
  775. })
  776. .catch(() => {
  777. // 角色加载失败不阻塞用户维护,下拉为空即可
  778. });
  779. return () => {
  780. cancelled = true;
  781. };
  782. }, [roleApiImpl]);
  783. const { tree } = useOrganizationTree(orgKeyword, orgRetry, orgApiImpl);
  784. const [selectedOrgId, setSelectedOrgId] = useState<string | null>(null);
  785. const [includeDescendants, setIncludeDescendants] = useState(false);
  786. const [userKeyword, setUserKeyword] = useState("");
  787. const [userStatus, setUserStatus] = useState<EnabledStatusCode | "">("");
  788. const [page, setPage] = useState(1);
  789. const pageSize = 20;
  790. const [userRetry, setUserRetry] = useState(0);
  791. const userQuery: UserQuery = useMemo(
  792. () => ({
  793. organizationId: selectedOrgId ?? undefined,
  794. includeDescendants,
  795. keyword: userKeyword.trim() || undefined,
  796. status: userStatus || undefined,
  797. page,
  798. pageSize,
  799. }),
  800. [selectedOrgId, includeDescendants, userKeyword, userStatus, page],
  801. );
  802. const { data, status, error } = useUserDirectory(
  803. userQuery,
  804. userRetry,
  805. userApiImpl,
  806. );
  807. const [expanded, setExpanded] = useState<Set<string>>(() => new Set());
  808. const [userEdit, setUserEdit] = useState<UserEditState>({
  809. mode: null,
  810. target: null,
  811. form: null,
  812. rowVersion: 0,
  813. });
  814. const [resetState, setResetState] = useState<ResetState | null>(null);
  815. const [mutation, setMutation] = useState<ApiResultState>(initialResult);
  816. // 初次加载自动展开用户筛选机构树(同上 useRef 模式)
  817. const initialUserExpandDone = useRef(false);
  818. useEffect(() => {
  819. if (!initialUserExpandDone.current && tree.length > 0) {
  820. initialUserExpandDone.current = true;
  821. setExpanded(new Set(flattenOrganizations(tree).map((n) => n.id)));
  822. }
  823. }, [tree]);
  824. const refreshUsers = useCallback(() => setUserRetry((v) => v + 1), []);
  825. const refreshTree = useCallback(() => setOrgRetry((v) => v + 1), []);
  826. const openCreateUser = () => {
  827. setUserEdit({
  828. mode: "create",
  829. target: null,
  830. form: buildInitialUserForm("create", null, selectedOrgId),
  831. rowVersion: 0,
  832. });
  833. setMutation(initialResult);
  834. };
  835. const openEditUser = (user: UserSummary) => {
  836. setUserEdit({
  837. mode: "edit",
  838. target: user,
  839. form: buildInitialUserForm("edit", user, null),
  840. rowVersion: user.rowVersion,
  841. });
  842. setMutation(initialResult);
  843. };
  844. const closeUserEditor = () => {
  845. setUserEdit({
  846. mode: null,
  847. target: null,
  848. form: null,
  849. rowVersion: 0,
  850. });
  851. setMutation(initialResult);
  852. };
  853. const handleSubmitUser = async () => {
  854. if (!userEdit.form || !userEdit.mode) return;
  855. const form = userEdit.form;
  856. if (!form.username.trim() || !form.realName.trim()) {
  857. setMutation({ submitting: false, error: "用户名与姓名不能为空" });
  858. return;
  859. }
  860. if (userEdit.mode === "create" && form.initialPassword.length < 8) {
  861. setMutation({ submitting: false, error: "初始密码至少8位" });
  862. return;
  863. }
  864. setMutation({ submitting: true, error: null });
  865. try {
  866. if (userEdit.mode === "create") {
  867. const payload: UserCreateRequest = {
  868. username: form.username.trim(),
  869. realName: form.realName.trim(),
  870. initialPassword: form.initialPassword,
  871. organizationId: form.organizationId,
  872. roleCode: form.roleCode,
  873. roleId: form.roleCode === RoleCode.USER ? form.roleId : null,
  874. securityLevel: form.securityLevel,
  875. status: form.status,
  876. };
  877. await userApiImpl.create(payload);
  878. } else if (userEdit.target) {
  879. const payload: UserUpdateRequest = {
  880. realName: form.realName.trim(),
  881. organizationId: form.organizationId,
  882. roleCode: form.roleCode,
  883. roleId: form.roleCode === RoleCode.USER ? form.roleId : null,
  884. securityLevel: form.securityLevel,
  885. status: form.status,
  886. rowVersion: userEdit.rowVersion,
  887. };
  888. await userApiImpl.update(userEdit.target.id, payload);
  889. }
  890. closeUserEditor();
  891. refreshUsers();
  892. } catch (error) {
  893. setMutation({ submitting: false, error: describeError(error) });
  894. }
  895. };
  896. const handleDeleteUser = async (user: UserSummary) => {
  897. if (!(await confirmDialog({ message: `确认删除用户「${user.realName}」?`, danger: true }))) return;
  898. setMutation({ submitting: true, error: null });
  899. try {
  900. await userApiImpl.delete(user.id, user.rowVersion);
  901. setMutation(initialResult);
  902. refreshUsers();
  903. } catch (error) {
  904. setMutation({ submitting: false, error: describeError(error) });
  905. }
  906. };
  907. const handleResetPassword = async () => {
  908. if (!resetState) return;
  909. if (resetState.newPassword.length < 8) {
  910. setMutation({ submitting: false, error: "新密码至少8位" });
  911. return;
  912. }
  913. setMutation({ submitting: true, error: null });
  914. try {
  915. const payload: UserResetPasswordRequest = {
  916. newPassword: resetState.newPassword,
  917. rowVersion: resetState.target?.rowVersion ?? 0,
  918. };
  919. await userApiImpl.resetPassword(resetState.target!.id, payload);
  920. setResetState(null);
  921. setMutation(initialResult);
  922. refreshUsers();
  923. } catch (error) {
  924. setMutation({ submitting: false, error: describeError(error) });
  925. }
  926. };
  927. return (
  928. <div className="w-full h-full flex overflow-hidden">
  929. <div
  930. className="flex-shrink-0 border-r flex flex-col"
  931. style={{
  932. width: 320,
  933. background: W,
  934. borderColor: "rgba(49,145,235,0.15)",
  935. }}
  936. >
  937. <div
  938. className="flex-shrink-0 px-4 py-2 border-b text-base font-black tracking-wide"
  939. style={{
  940. color: N,
  941. borderColor: "rgba(49,145,235,0.15)",
  942. background: PBG,
  943. }}
  944. >
  945. 机构筛选
  946. </div>
  947. <div className="flex-shrink-0 px-3 py-2 border-b" style={{ borderColor: "rgba(49,145,235,0.1)" }}>
  948. <input
  949. value={orgKeyword}
  950. onChange={(e) => setOrgKeyword(e.target.value)}
  951. placeholder="按机构名称或编码筛选"
  952. className="w-full px-2.5 py-1.5 text-base border rounded-sm outline-none"
  953. style={{ borderColor: "rgba(49,145,235,0.4)", color: N }}
  954. />
  955. </div>
  956. <div className="flex-1 min-h-0 overflow-auto py-1">
  957. <button
  958. type="button"
  959. onClick={() => setSelectedOrgId(null)}
  960. className="w-full text-left px-3 py-1.5 text-base font-bold transition-colors"
  961. style={{
  962. color: selectedOrgId === null ? W : N,
  963. background: selectedOrgId === null ? B : "transparent",
  964. }}
  965. >
  966. 全部机构
  967. </button>
  968. {tree.map((node) => (
  969. <UserOrgTreeRow
  970. key={node.id}
  971. node={node}
  972. depth={0}
  973. expanded={expanded}
  974. onToggle={(id) =>
  975. setExpanded((current) => {
  976. const next = new Set(current);
  977. if (next.has(id)) next.delete(id);
  978. else next.add(id);
  979. return next;
  980. })
  981. }
  982. selectedId={selectedOrgId}
  983. onSelect={setSelectedOrgId}
  984. />
  985. ))}
  986. </div>
  987. <div
  988. className="flex-shrink-0 px-3 py-2 border-t flex items-center justify-between"
  989. style={{ borderColor: "rgba(49,145,235,0.15)" }}
  990. >
  991. <label
  992. className="flex items-center gap-2 text-base font-bold cursor-pointer"
  993. style={{ color: N }}
  994. >
  995. <input
  996. type="checkbox"
  997. checked={includeDescendants}
  998. onChange={(e) => setIncludeDescendants(e.target.checked)}
  999. />
  1000. 包含下级机构
  1001. </label>
  1002. {smallButton(false, {
  1003. label: "刷新",
  1004. icon: <RefreshCw size={12} />,
  1005. onClick: refreshTree,
  1006. })}
  1007. </div>
  1008. </div>
  1009. <div className="flex-1 min-w-0 flex flex-col overflow-hidden">
  1010. <div
  1011. className="flex-shrink-0 flex items-center justify-between gap-3 px-6 py-3 border-b"
  1012. style={{ background: W, borderColor: "rgba(49,145,235,0.15)" }}
  1013. >
  1014. <div className="flex items-center gap-2 flex-1 max-w-md">
  1015. <input
  1016. value={userKeyword}
  1017. onChange={(e) => {
  1018. setUserKeyword(e.target.value);
  1019. setPage(1);
  1020. }}
  1021. placeholder="按用户名或姓名筛选"
  1022. className="flex-1 px-3 py-1.5 text-base border rounded-sm outline-none"
  1023. style={{ borderColor: "rgba(49,145,235,0.4)", color: N }}
  1024. />
  1025. {userKeyword && (
  1026. <button
  1027. type="button"
  1028. onClick={() => setUserKeyword("")}
  1029. className="text-base font-bold opacity-60 hover:opacity-100"
  1030. style={{ color: N }}
  1031. >
  1032. <X size={14} />
  1033. </button>
  1034. )}
  1035. <select
  1036. value={userStatus}
  1037. onChange={(e) => {
  1038. setUserStatus(
  1039. (e.target.value || "") as EnabledStatusCode | "",
  1040. );
  1041. setPage(1);
  1042. }}
  1043. className="px-2 py-1.5 text-base border rounded-sm outline-none"
  1044. style={{ borderColor: "rgba(49,145,235,0.4)", color: N }}
  1045. >
  1046. <option value="">全部状态</option>
  1047. {statusOptions().map((code) => (
  1048. <option key={code} value={code}>
  1049. {dictionaries.label("enabledStatuses", code)}
  1050. </option>
  1051. ))}
  1052. </select>
  1053. </div>
  1054. <div className="flex items-center gap-1.5">
  1055. {smallButton(false, {
  1056. label: "刷新",
  1057. icon: <RefreshCw size={12} />,
  1058. onClick: refreshUsers,
  1059. })}
  1060. {smallButton(true, {
  1061. label: "新建用户",
  1062. icon: <Plus size={12} />,
  1063. onClick: openCreateUser,
  1064. })}
  1065. </div>
  1066. </div>
  1067. <div
  1068. className="flex-1 min-h-0 overflow-auto px-6 py-4"
  1069. style={{ background: PBG }}
  1070. >
  1071. {status === "loading" && (
  1072. <div
  1073. className="text-base font-bold text-center py-8"
  1074. style={{ color: N }}
  1075. >
  1076. 正在加载用户列表…
  1077. </div>
  1078. )}
  1079. {status === "error" && (
  1080. <div
  1081. className="text-base font-bold text-center py-8"
  1082. style={{ color: DANGER }}
  1083. >
  1084. {error?.message ?? "加载失败"}
  1085. </div>
  1086. )}
  1087. {status !== "loading" && status !== "error" && data.items.length === 0 && (
  1088. <div
  1089. className="text-base font-bold text-center py-8"
  1090. style={{ color: N }}
  1091. >
  1092. 暂无用户,请点击右上角"新建用户"。
  1093. </div>
  1094. )}
  1095. {data.items.length > 0 && (
  1096. <table
  1097. className="w-full text-base"
  1098. style={{ background: W, borderCollapse: "collapse" }}
  1099. >
  1100. <thead>
  1101. <tr style={{ background: PBG }}>
  1102. {[
  1103. "用户名",
  1104. "姓名",
  1105. "所属机构",
  1106. "角色",
  1107. "密级",
  1108. "状态",
  1109. "最近登录",
  1110. "操作",
  1111. ].map((h) => (
  1112. <th
  1113. key={h}
  1114. className="text-left px-3 py-2 text-base font-black border"
  1115. style={{
  1116. color: N,
  1117. borderColor: "rgba(49,145,235,0.2)",
  1118. }}
  1119. >
  1120. {h}
  1121. </th>
  1122. ))}
  1123. </tr>
  1124. </thead>
  1125. <tbody>
  1126. {data.items.map((user) => (
  1127. <tr
  1128. key={user.id}
  1129. className="border-t transition-colors hover:bg-[rgba(49,145,235,0.18)]"
  1130. style={{ borderColor: "rgba(49,145,235,0.12)" }}
  1131. >
  1132. <td className="px-3 py-2 font-bold" style={{ color: N }}>
  1133. {user.username}
  1134. </td>
  1135. <td className="px-3 py-2 text-base font-bold" style={{ color: N }}>
  1136. {user.realName}
  1137. </td>
  1138. <td className="px-3 py-2 text-base font-bold" style={{ color: N }}>
  1139. {user.organizationName || "—"}
  1140. </td>
  1141. <td className="px-3 py-2 text-base font-bold" style={{ color: N }}>
  1142. {user.roleCode === RoleCode.USER && user.roleName
  1143. ? user.roleName
  1144. : dictionaries.label("roleCodes", user.roleCode)}
  1145. </td>
  1146. <td className="px-3 py-2 text-base font-bold" style={{ color: N }}>
  1147. {dictionaries.label("securityLevels", user.securityLevel)}
  1148. </td>
  1149. <td className="px-3 py-2 text-base font-bold" style={{ color: N }}>
  1150. {dictionaries.label("enabledStatuses", user.status)}
  1151. </td>
  1152. <td className="px-3 py-2 text-base" style={{ color: N }}>
  1153. {user.lastLoginAt ?? "—"}
  1154. </td>
  1155. <td className="px-3 py-2">
  1156. <div className="flex items-center gap-1.5">
  1157. {smallButton(false, {
  1158. label: "编辑",
  1159. icon: <Edit2 size={12} />,
  1160. onClick: () => openEditUser(user),
  1161. })}
  1162. {smallButton(false, {
  1163. label: "重置密码",
  1164. icon: <Key size={12} />,
  1165. onClick: () =>
  1166. setResetState({
  1167. target: user,
  1168. newPassword: "",
  1169. }),
  1170. })}
  1171. {smallButton(false, {
  1172. label: "删除",
  1173. icon: <Trash2 size={12} />,
  1174. onClick: () => handleDeleteUser(user),
  1175. danger: true,
  1176. })}
  1177. </div>
  1178. </td>
  1179. </tr>
  1180. ))}
  1181. </tbody>
  1182. </table>
  1183. )}
  1184. </div>
  1185. <div
  1186. className="flex-shrink-0 flex items-center justify-between px-6 py-2 border-t text-base font-bold"
  1187. style={{
  1188. color: N,
  1189. background: W,
  1190. borderColor: "rgba(49,145,235,0.15)",
  1191. }}
  1192. >
  1193. <span>
  1194. 共 {data.total} 条 · 第 {data.page} / {Math.max(data.totalPages, 1)} 页
  1195. </span>
  1196. <div className="flex items-center gap-1.5">
  1197. <button
  1198. type="button"
  1199. onClick={() => setPage((p) => Math.max(1, p - 1))}
  1200. disabled={data.page <= 1}
  1201. className="px-2.5 py-1 text-base font-bold rounded-sm disabled:opacity-40"
  1202. style={{ border: `1px solid ${B}`, color: N, background: W }}
  1203. >
  1204. 上一页
  1205. </button>
  1206. <button
  1207. type="button"
  1208. onClick={() =>
  1209. setPage((p) =>
  1210. Math.min(Math.max(data.totalPages, 1), p + 1),
  1211. )
  1212. }
  1213. disabled={data.page >= data.totalPages}
  1214. className="px-2.5 py-1 text-base font-bold rounded-sm disabled:opacity-40"
  1215. style={{ border: `1px solid ${B}`, color: N, background: W }}
  1216. >
  1217. 下一页
  1218. </button>
  1219. </div>
  1220. </div>
  1221. </div>
  1222. {mutation.error && (
  1223. <div
  1224. className="flex-shrink-0 px-6 py-2 text-base font-bold"
  1225. style={{ color: DANGER, background: "rgba(192,57,43,0.05)" }}
  1226. >
  1227. {mutation.error}
  1228. </div>
  1229. )}
  1230. {userEdit.form && userEdit.mode && (
  1231. <UserEditorModal
  1232. mode={userEdit.mode}
  1233. target={userEdit.target}
  1234. form={userEdit.form}
  1235. organizations={flattenOrganizations(tree)}
  1236. roles={roles}
  1237. onChange={(patch) =>
  1238. setUserEdit((current) =>
  1239. current.form
  1240. ? { ...current, form: { ...current.form, ...patch } }
  1241. : current,
  1242. )
  1243. }
  1244. onClose={closeUserEditor}
  1245. onSubmit={handleSubmitUser}
  1246. submitting={mutation.submitting}
  1247. />
  1248. )}
  1249. {resetState && resetState.target && (
  1250. <ResetPasswordModal
  1251. target={resetState.target}
  1252. value={resetState.newPassword}
  1253. onChange={(v) =>
  1254. setResetState((current) =>
  1255. current ? { ...current, newPassword: v } : current,
  1256. )
  1257. }
  1258. onClose={() => {
  1259. setResetState(null);
  1260. setMutation(initialResult);
  1261. }}
  1262. onSubmit={handleResetPassword}
  1263. submitting={mutation.submitting}
  1264. />
  1265. )}
  1266. {confirmDialogNode}
  1267. </div>
  1268. );
  1269. }
  1270. function UserOrgTreeRow({
  1271. node,
  1272. depth,
  1273. expanded,
  1274. onToggle,
  1275. selectedId,
  1276. onSelect,
  1277. }: {
  1278. node: OrganizationNode;
  1279. depth: number;
  1280. expanded: ReadonlySet<string>;
  1281. onToggle: (id: string) => void;
  1282. selectedId: string | null;
  1283. onSelect: (id: string) => void;
  1284. }) {
  1285. // 防御性:避免极端深度时栈溢出(后端 _descendants 已做循环检测,此处为前端二次保护)
  1286. if (depth >= MAX_TREE_DEPTH) return null;
  1287. const isOpen = expanded.has(node.id);
  1288. const isSelected = selectedId === node.id;
  1289. const hasChildren = node.children.length > 0;
  1290. return (
  1291. <>
  1292. <div
  1293. 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)]" : ""}`}
  1294. style={{ paddingLeft: 12 + depth * 14 }}
  1295. onClick={() => onSelect(node.id)}
  1296. >
  1297. {hasChildren ? (
  1298. <button
  1299. type="button"
  1300. onClick={(e) => {
  1301. e.stopPropagation();
  1302. onToggle(node.id);
  1303. }}
  1304. className="opacity-70 hover:opacity-100"
  1305. style={{ color: N }}
  1306. >
  1307. {isOpen ? <ChevronDown size={11} /> : <ChevronRight size={11} />}
  1308. </button>
  1309. ) : (
  1310. <span className="inline-block w-[11px]" />
  1311. )}
  1312. <span className="text-base font-bold truncate flex-1" style={{ color: N }}>
  1313. {node.orgName}
  1314. </span>
  1315. {isSelected && (
  1316. <span
  1317. className="text-base font-black px-1.5 py-0.5 rounded-sm"
  1318. style={{ background: B, color: W }}
  1319. >
  1320. 已选
  1321. </span>
  1322. )}
  1323. </div>
  1324. {isOpen &&
  1325. node.children.map((child) => (
  1326. <UserOrgTreeRow
  1327. key={child.id}
  1328. node={child}
  1329. depth={depth + 1}
  1330. expanded={expanded}
  1331. onToggle={onToggle}
  1332. selectedId={selectedId}
  1333. onSelect={onSelect}
  1334. />
  1335. ))}
  1336. </>
  1337. );
  1338. }
  1339. function UserEditorModal({
  1340. mode,
  1341. target,
  1342. form,
  1343. organizations,
  1344. roles,
  1345. onChange,
  1346. onClose,
  1347. onSubmit,
  1348. submitting,
  1349. }: {
  1350. mode: "create" | "edit";
  1351. target: UserSummary | null;
  1352. form: UserForm;
  1353. organizations: OrganizationNode[];
  1354. roles: RoleSummary[];
  1355. onChange: (patch: Partial<UserForm>) => void;
  1356. onClose: () => void;
  1357. onSubmit: () => void;
  1358. submitting: boolean;
  1359. }) {
  1360. const dictionaries = useDictionaries();
  1361. const title = mode === "create" ? "新建用户" : `编辑用户:${target?.realName ?? ""}`;
  1362. const isEdit = mode === "edit";
  1363. const orgSelectOptions = useMemo<OptionEntry<string>[]>(
  1364. () =>
  1365. organizations.map((node) => ({
  1366. value: node.id,
  1367. label: `${node.orgPath} (${node.orgCode})`,
  1368. })),
  1369. [organizations],
  1370. );
  1371. const roleSelectOptions = useMemo<OptionEntry<RoleCodeValue>[]>(
  1372. () =>
  1373. roleOptions().map((code) => ({
  1374. value: code,
  1375. label: dictionaries.label("roleCodes", code),
  1376. })),
  1377. [dictionaries],
  1378. );
  1379. const customRoleOptions = useMemo<OptionEntry<string>[]>(
  1380. () =>
  1381. roles.map((role) => ({
  1382. value: role.id,
  1383. label:
  1384. role.status === EnabledStatus.ENABLED
  1385. ? role.roleName
  1386. : `${role.roleName}(已停用)`,
  1387. })),
  1388. [roles],
  1389. );
  1390. const securitySelectOptions = useMemo<OptionEntry<SecurityLevelValue>[]>(
  1391. () =>
  1392. securityLevelOptions().map((code) => ({
  1393. value: code,
  1394. label: dictionaries.label("securityLevels", code),
  1395. })),
  1396. [dictionaries],
  1397. );
  1398. const statusSelectOptions = useMemo<OptionEntry<EnabledStatusCode>[]>(
  1399. () =>
  1400. statusOptions().map((code) => ({
  1401. value: code,
  1402. label: dictionaries.label("enabledStatuses", code),
  1403. })),
  1404. [dictionaries],
  1405. );
  1406. return (
  1407. <ModalShell title={title} onClose={onClose}>
  1408. <div className="space-y-3">
  1409. <LabeledInput
  1410. label="用户名"
  1411. value={form.username}
  1412. onChange={(v) => onChange({ username: v })}
  1413. disabled={isEdit}
  1414. placeholder="登录用户名"
  1415. />
  1416. <LabeledInput
  1417. label="姓名"
  1418. value={form.realName}
  1419. onChange={(v) => onChange({ realName: v })}
  1420. />
  1421. {mode === "create" && (
  1422. <LabeledInput
  1423. label="初始密码(至少8位)"
  1424. value={form.initialPassword}
  1425. onChange={(v) => onChange({ initialPassword: v })}
  1426. type="password"
  1427. autoComplete="new-password"
  1428. />
  1429. )}
  1430. <LabeledSelect<string>
  1431. label="所属机构"
  1432. value={form.organizationId ?? ""}
  1433. onChange={(v) => onChange({ organizationId: v || null })}
  1434. options={[
  1435. { value: "", label: "— 未指定 —" },
  1436. ...orgSelectOptions,
  1437. ]}
  1438. />
  1439. <LabeledSelect<RoleCodeValue>
  1440. label="用户类型"
  1441. value={form.roleCode}
  1442. onChange={(v) =>
  1443. onChange(
  1444. v === RoleCode.USER
  1445. ? { roleCode: v }
  1446. : { roleCode: v, roleId: null },
  1447. )
  1448. }
  1449. options={roleSelectOptions}
  1450. />
  1451. {form.roleCode === RoleCode.USER && (
  1452. <LabeledSelect<string>
  1453. label="角色"
  1454. value={form.roleId ?? ""}
  1455. onChange={(v) => onChange({ roleId: v || null })}
  1456. options={[
  1457. { value: "", label: "— 未指定(跟随默认角色) —" },
  1458. ...customRoleOptions,
  1459. ]}
  1460. />
  1461. )}
  1462. <LabeledSelect<SecurityLevelValue>
  1463. label="密级"
  1464. value={form.securityLevel}
  1465. onChange={(v) => onChange({ securityLevel: v })}
  1466. options={securitySelectOptions}
  1467. />
  1468. <LabeledSelect<EnabledStatusCode>
  1469. label="状态"
  1470. value={form.status}
  1471. onChange={(v) => onChange({ status: v })}
  1472. options={statusSelectOptions}
  1473. />
  1474. </div>
  1475. <ModalFooter
  1476. onClose={onClose}
  1477. onSubmit={onSubmit}
  1478. submitting={submitting}
  1479. submitLabel={mode === "create" ? "创建" : "保存"}
  1480. />
  1481. </ModalShell>
  1482. );
  1483. }
  1484. function ResetPasswordModal({
  1485. target,
  1486. value,
  1487. onChange,
  1488. onClose,
  1489. onSubmit,
  1490. submitting,
  1491. }: {
  1492. target: UserSummary;
  1493. value: string;
  1494. onChange: (v: string) => void;
  1495. onClose: () => void;
  1496. onSubmit: () => void;
  1497. submitting: boolean;
  1498. }) {
  1499. return (
  1500. <ModalShell
  1501. title={`重置密码:${target.realName}(${target.username})`}
  1502. onClose={onClose}
  1503. >
  1504. <div className="space-y-3">
  1505. <div
  1506. className="text-base font-bold flex items-center gap-1 p-2 rounded-sm"
  1507. style={{ color: DANGER, background: "rgba(192,57,43,0.05)" }}
  1508. >
  1509. <Lock size={12} />
  1510. 新密码生效后该用户原有登录凭证立即失效。
  1511. </div>
  1512. <LabeledInput
  1513. label="新密码(至少8位)"
  1514. value={value}
  1515. onChange={onChange}
  1516. type="password"
  1517. autoComplete="new-password"
  1518. />
  1519. </div>
  1520. <ModalFooter
  1521. onClose={onClose}
  1522. onSubmit={onSubmit}
  1523. submitting={submitting}
  1524. submitLabel="重置密码"
  1525. danger
  1526. />
  1527. </ModalShell>
  1528. );
  1529. }