From 3beaa61ae870b4f7435bede82d23a4cdca84c5c9 Mon Sep 17 00:00:00 2001 From: zichun Date: Thu, 30 Apr 2026 19:19:41 +0800 Subject: [PATCH] feat(staff-picker): add staff search endpoint and UserDetail typeahead --- backend/src/main/java/com/xly/erp/module/usr/controller/StaffController.java | 33 +++++++++++++++++++++++++++++++++ backend/src/main/java/com/xly/erp/module/usr/mapper/StaffMapper.java | 18 ++++++++++++++++++ backend/src/main/java/com/xly/erp/module/usr/vo/StaffSearchVO.java | 27 +++++++++++++++++++++++++++ backend/src/main/java/com/xly/erp/module/usr/vo/UserListVO.java | 5 +++++ backend/src/main/resources/mapper/usr/UserMapper.xml | 1 + frontend/src/api/staff.ts | 16 ++++++++++++++++ frontend/src/api/user.ts | 1 + frontend/src/components/StaffPicker.tsx | 196 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ frontend/src/pages/usr/UserDetail.tsx | 16 ++++++++++++---- 9 files changed, 309 insertions(+), 4 deletions(-) create mode 100644 backend/src/main/java/com/xly/erp/module/usr/controller/StaffController.java create mode 100644 backend/src/main/java/com/xly/erp/module/usr/vo/StaffSearchVO.java create mode 100644 frontend/src/api/staff.ts create mode 100644 frontend/src/components/StaffPicker.tsx diff --git a/backend/src/main/java/com/xly/erp/module/usr/controller/StaffController.java b/backend/src/main/java/com/xly/erp/module/usr/controller/StaffController.java new file mode 100644 index 0000000..897ee9a --- /dev/null +++ b/backend/src/main/java/com/xly/erp/module/usr/controller/StaffController.java @@ -0,0 +1,33 @@ +package com.xly.erp.module.usr.controller; + +import com.xly.erp.common.response.Result; +import com.xly.erp.module.usr.mapper.StaffMapper; +import com.xly.erp.module.usr.vo.StaffSearchVO; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RestController +@RequestMapping("/api/usr") +public class StaffController { + + private static final int DEFAULT_LIMIT = 20; + private static final int MAX_LIMIT = 50; + + private final StaffMapper staffMapper; + + public StaffController(StaffMapper staffMapper) { + this.staffMapper = staffMapper; + } + + @GetMapping("/staffs") + public Result> search(@RequestParam(required = false) String keyword, + @RequestParam(required = false) Integer limit) { + int n = (limit == null || limit < 1) ? DEFAULT_LIMIT : Math.min(limit, MAX_LIMIT); + String kw = keyword == null ? "" : keyword.trim(); + return Result.ok(staffMapper.searchActive(kw, n)); + } +} diff --git a/backend/src/main/java/com/xly/erp/module/usr/mapper/StaffMapper.java b/backend/src/main/java/com/xly/erp/module/usr/mapper/StaffMapper.java index e06946f..5b826eb 100644 --- a/backend/src/main/java/com/xly/erp/module/usr/mapper/StaffMapper.java +++ b/backend/src/main/java/com/xly/erp/module/usr/mapper/StaffMapper.java @@ -1,9 +1,12 @@ package com.xly.erp.module.usr.mapper; +import com.xly.erp.module.usr.vo.StaffSearchVO; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Select; +import java.util.List; + @Mapper public interface StaffMapper { @@ -13,4 +16,19 @@ public interface StaffMapper { default boolean existsActiveById(Integer id) { return findActiveStaffFlag(id) != null; } + + @Select(""" + + """) + List searchActive(@Param("keyword") String keyword, @Param("limit") Integer limit); } diff --git a/backend/src/main/java/com/xly/erp/module/usr/vo/StaffSearchVO.java b/backend/src/main/java/com/xly/erp/module/usr/vo/StaffSearchVO.java new file mode 100644 index 0000000..e97a580 --- /dev/null +++ b/backend/src/main/java/com/xly/erp/module/usr/vo/StaffSearchVO.java @@ -0,0 +1,27 @@ +package com.xly.erp.module.usr.vo; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public class StaffSearchVO { + + @JsonProperty("iIncrement") + private Integer iIncrement; + + @JsonProperty("sStaffNo") + private String sStaffNo; + + @JsonProperty("sStaffName") + private String sStaffName; + + @JsonProperty("sDepartment") + private String sDepartment; + + public Integer getIIncrement() { return iIncrement; } + public void setIIncrement(Integer iIncrement) { this.iIncrement = iIncrement; } + public String getSStaffNo() { return sStaffNo; } + public void setSStaffNo(String sStaffNo) { this.sStaffNo = sStaffNo; } + public String getSStaffName() { return sStaffName; } + public void setSStaffName(String sStaffName) { this.sStaffName = sStaffName; } + public String getSDepartment() { return sDepartment; } + public void setSDepartment(String sDepartment) { this.sDepartment = sDepartment; } +} diff --git a/backend/src/main/java/com/xly/erp/module/usr/vo/UserListVO.java b/backend/src/main/java/com/xly/erp/module/usr/vo/UserListVO.java index 04bf148..c2234b9 100644 --- a/backend/src/main/java/com/xly/erp/module/usr/vo/UserListVO.java +++ b/backend/src/main/java/com/xly/erp/module/usr/vo/UserListVO.java @@ -12,6 +12,9 @@ public class UserListVO { @JsonProperty("sUserName") private String sUserName; + @JsonProperty("iStaffId") + private Integer iStaffId; + @JsonProperty("staffName") private String staffName; @@ -43,6 +46,8 @@ public class UserListVO { public void setIIncrement(Integer iIncrement) { this.iIncrement = iIncrement; } public String getSUserName() { return sUserName; } public void setSUserName(String sUserName) { this.sUserName = sUserName; } + public Integer getIStaffId() { return iStaffId; } + public void setIStaffId(Integer iStaffId) { this.iStaffId = iStaffId; } public String getStaffName() { return staffName; } public void setStaffName(String staffName) { this.staffName = staffName; } public String getSUserNo() { return sUserNo; } diff --git a/backend/src/main/resources/mapper/usr/UserMapper.xml b/backend/src/main/resources/mapper/usr/UserMapper.xml index 28caade..af78c0c 100644 --- a/backend/src/main/resources/mapper/usr/UserMapper.xml +++ b/backend/src/main/resources/mapper/usr/UserMapper.xml @@ -6,6 +6,7 @@ u.iIncrement AS iIncrement, u.sUserName AS sUserName, + u.iStaffId AS iStaffId, s.sStaffName AS staffName, u.sUserNo AS sUserNo, s.sDepartment AS department, diff --git a/frontend/src/api/staff.ts b/frontend/src/api/staff.ts new file mode 100644 index 0000000..fd242ef --- /dev/null +++ b/frontend/src/api/staff.ts @@ -0,0 +1,16 @@ +import { request } from "./client"; + +export interface StaffSearchVO { + iIncrement: number; + sStaffNo: string; + sStaffName: string; + sDepartment: string | null; +} + +export function searchStaff(keyword?: string, limit = 20): Promise { + return request({ + url: "/usr/staffs", + method: "GET", + params: { keyword: keyword ?? "", limit }, + }); +} diff --git a/frontend/src/api/user.ts b/frontend/src/api/user.ts index 6f7d1dd..3f5c777 100644 --- a/frontend/src/api/user.ts +++ b/frontend/src/api/user.ts @@ -3,6 +3,7 @@ import { request } from "./client"; export interface UserListVO { iIncrement: number; sUserName: string; + iStaffId: number | null; staffName: string | null; sUserNo: string; department: string | null; diff --git a/frontend/src/components/StaffPicker.tsx b/frontend/src/components/StaffPicker.tsx new file mode 100644 index 0000000..c422d26 --- /dev/null +++ b/frontend/src/components/StaffPicker.tsx @@ -0,0 +1,196 @@ +import { useEffect, useRef, useState } from "react"; +import { SearchOutlined } from "@ant-design/icons"; +import { searchStaff, type StaffSearchVO } from "@/api/staff"; + +interface Props { + value: string; + staffId: number | null; + onChange: (name: string, staffId: number | null) => void; + disabled?: boolean; + required?: boolean; + placeholder?: string; +} + +const fieldControl: React.CSSProperties = { + flex: 1, + minWidth: 0, + height: "var(--input-h)", + border: "1px solid var(--border-input)", + background: "var(--bg-input)", + padding: "0 26px 0 6px", + fontSize: 12, + color: "var(--text)", + borderRadius: 0, + outline: "none", + fontFamily: "inherit", +}; + +export default function StaffPicker({ + value, + staffId, + onChange, + disabled, + required, + placeholder, +}: Props) { + const [open, setOpen] = useState(false); + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(false); + const wrapRef = useRef(null); + const debounce = useRef(null); + + useEffect(() => { + function onDocClick(e: MouseEvent) { + if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) { + setOpen(false); + } + } + document.addEventListener("mousedown", onDocClick); + return () => document.removeEventListener("mousedown", onDocClick); + }, []); + + const fetchSuggestions = async (kw: string) => { + setLoading(true); + try { + const list = await searchStaff(kw, 20); + setItems(list); + } catch { + setItems([]); + } finally { + setLoading(false); + } + }; + + const onFocus = () => { + if (disabled) return; + setOpen(true); + void fetchSuggestions(value); + }; + + const onInputChange = (next: string) => { + // Typing clears any previously bound staff id; user must re-select + onChange(next, null); + setOpen(true); + if (debounce.current) window.clearTimeout(debounce.current); + debounce.current = window.setTimeout(() => fetchSuggestions(next), 200); + }; + + const select = (s: StaffSearchVO) => { + onChange(s.sStaffName, s.iIncrement); + setOpen(false); + }; + + return ( +
+ onInputChange(e.target.value)} + onFocus={onFocus} + disabled={disabled} + placeholder={placeholder} + style={{ + ...fieldControl, + ...(disabled ? { background: "var(--bg-disabled)", color: "var(--text-muted)" } : {}), + ...(required && !disabled ? { background: "#d4e8f7" } : {}), + }} + /> + + + + {staffId != null && !disabled && ( + + 已绑定 + + )} + {open && !disabled && ( +
+ {loading && ( +
+ 加载中… +
+ )} + {!loading && items.length === 0 && ( +
+ 没有匹配的职员 +
+ )} + {!loading && + items.map((s) => ( +
{ + e.preventDefault(); + select(s); + }} + style={{ + padding: "6px 12px", + fontSize: 12, + cursor: "pointer", + display: "flex", + alignItems: "center", + gap: 8, + borderBottom: "1px solid #f0f2f5", + }} + onMouseEnter={(e) => { + e.currentTarget.style.background = "var(--bg-row-hover)"; + }} + onMouseLeave={(e) => { + e.currentTarget.style.background = "transparent"; + }} + > + {s.sStaffName} + + {s.sStaffNo} + + + + {s.sDepartment ?? ""} + +
+ ))} +
+ )} +
+ ); +} diff --git a/frontend/src/pages/usr/UserDetail.tsx b/frontend/src/pages/usr/UserDetail.tsx index da0b465..2358cd1 100644 --- a/frontend/src/pages/usr/UserDetail.tsx +++ b/frontend/src/pages/usr/UserDetail.tsx @@ -19,6 +19,7 @@ import { PrimCheckbox, ToolbarBtnDark, } from "@/components/Primitives"; +import StaffPicker from "@/components/StaffPicker"; import { createUser, updateUser, type UserDTO } from "@/api/user"; import { USER_TYPES, @@ -41,6 +42,7 @@ interface Props { interface FormState { sUserNo: string; sUserName: string; + iStaffId: number | null; staffName: string; department: string; sUserType: string; @@ -69,6 +71,7 @@ export default function UserDetail({ userId, mode: initialMode }: Props) { | { sUserNo: string; sUserName: string; + iStaffId: number | null; staffName: string | null; department: string | null; sUserType: string; @@ -84,6 +87,7 @@ export default function UserDetail({ userId, mode: initialMode }: Props) { ? { sUserNo: "", sUserName: "", + iStaffId: null, staffName: "", department: "", sUserType: "普通用户", @@ -93,6 +97,7 @@ export default function UserDetail({ userId, mode: initialMode }: Props) { : { sUserNo: snapshot.sUserNo, sUserName: snapshot.sUserName, + iStaffId: snapshot.iStaffId, staffName: snapshot.staffName ?? "", department: snapshot.department ?? "", sUserType: snapshot.sUserType, @@ -139,10 +144,10 @@ export default function UserDetail({ userId, mode: initialMode }: Props) { const dto: UserDTO = { sUserNo: form.sUserNo, sUserName: form.sUserName, + iStaffId: form.iStaffId, sUserType: form.sUserType, sLanguage: form.sLanguage, bCanModifyDocs: form.bCanModifyDocs, - // iStaffId omitted: prototype has no staff picker. // permissionCategoryIds omitted: prototype permissions are by name; backend wants IDs. }; setSubmitting(true); @@ -297,12 +302,15 @@ export default function UserDetail({ userId, mode: initialMode }: Props) { /> - set("staffName", v)} + staffId={form.iStaffId} + onChange={(name, id) => + setForm((s) => ({ ...s, staffName: name, iStaffId: id })) + } disabled={disabled} required - placeholder="(关联职员)" + placeholder="输入员工姓名搜索" /> -- libgit2 0.22.2