Commit 3beaa61ae870b4f7435bede82d23a4cdca84c5c9
1 parent
2f4874be
feat(staff-picker): add staff search endpoint and UserDetail typeahead
Backend: - StaffSearchVO (iIncrement / sStaffNo / sStaffName / sDepartment) - StaffMapper.searchActive(keyword, limit) — LIKE on name + staff no - StaffController GET /api/usr/staffs?keyword=&limit= - UserListVO + UserMapper.xml: expose iStaffId so edit mode preserves the binding when user doesn't change 员工名 Frontend: - api/staff.ts → searchStaff() - StaffPicker.tsx: input + dropdown with debounced search, '已绑定' badge when an iStaffId is bound, click outside to close, search icon - UserDetail: replace 员工名 PrimInput with StaffPicker; track iStaffId in form state; send to backend on save (no longer omitted)
Showing
9 changed files
with
309 additions
and
4 deletions
backend/src/main/java/com/xly/erp/module/usr/controller/StaffController.java
0 → 100644
| 1 | +package com.xly.erp.module.usr.controller; | |
| 2 | + | |
| 3 | +import com.xly.erp.common.response.Result; | |
| 4 | +import com.xly.erp.module.usr.mapper.StaffMapper; | |
| 5 | +import com.xly.erp.module.usr.vo.StaffSearchVO; | |
| 6 | +import org.springframework.web.bind.annotation.GetMapping; | |
| 7 | +import org.springframework.web.bind.annotation.RequestMapping; | |
| 8 | +import org.springframework.web.bind.annotation.RequestParam; | |
| 9 | +import org.springframework.web.bind.annotation.RestController; | |
| 10 | + | |
| 11 | +import java.util.List; | |
| 12 | + | |
| 13 | +@RestController | |
| 14 | +@RequestMapping("/api/usr") | |
| 15 | +public class StaffController { | |
| 16 | + | |
| 17 | + private static final int DEFAULT_LIMIT = 20; | |
| 18 | + private static final int MAX_LIMIT = 50; | |
| 19 | + | |
| 20 | + private final StaffMapper staffMapper; | |
| 21 | + | |
| 22 | + public StaffController(StaffMapper staffMapper) { | |
| 23 | + this.staffMapper = staffMapper; | |
| 24 | + } | |
| 25 | + | |
| 26 | + @GetMapping("/staffs") | |
| 27 | + public Result<List<StaffSearchVO>> search(@RequestParam(required = false) String keyword, | |
| 28 | + @RequestParam(required = false) Integer limit) { | |
| 29 | + int n = (limit == null || limit < 1) ? DEFAULT_LIMIT : Math.min(limit, MAX_LIMIT); | |
| 30 | + String kw = keyword == null ? "" : keyword.trim(); | |
| 31 | + return Result.ok(staffMapper.searchActive(kw, n)); | |
| 32 | + } | |
| 33 | +} | ... | ... |
backend/src/main/java/com/xly/erp/module/usr/mapper/StaffMapper.java
| 1 | 1 | package com.xly.erp.module.usr.mapper; |
| 2 | 2 | |
| 3 | +import com.xly.erp.module.usr.vo.StaffSearchVO; | |
| 3 | 4 | import org.apache.ibatis.annotations.Mapper; |
| 4 | 5 | import org.apache.ibatis.annotations.Param; |
| 5 | 6 | import org.apache.ibatis.annotations.Select; |
| 6 | 7 | |
| 8 | +import java.util.List; | |
| 9 | + | |
| 7 | 10 | @Mapper |
| 8 | 11 | public interface StaffMapper { |
| 9 | 12 | |
| ... | ... | @@ -13,4 +16,19 @@ public interface StaffMapper { |
| 13 | 16 | default boolean existsActiveById(Integer id) { |
| 14 | 17 | return findActiveStaffFlag(id) != null; |
| 15 | 18 | } |
| 19 | + | |
| 20 | + @Select(""" | |
| 21 | + <script> | |
| 22 | + SELECT iIncrement, sStaffNo, sStaffName, sDepartment | |
| 23 | + FROM tStaff | |
| 24 | + WHERE bDeleted = 0 | |
| 25 | + <if test='keyword != null and keyword != ""'> | |
| 26 | + AND (sStaffName LIKE CONCAT('%', #{keyword}, '%') | |
| 27 | + OR sStaffNo LIKE CONCAT('%', #{keyword}, '%')) | |
| 28 | + </if> | |
| 29 | + ORDER BY sStaffName ASC | |
| 30 | + LIMIT #{limit} | |
| 31 | + </script> | |
| 32 | + """) | |
| 33 | + List<StaffSearchVO> searchActive(@Param("keyword") String keyword, @Param("limit") Integer limit); | |
| 16 | 34 | } | ... | ... |
backend/src/main/java/com/xly/erp/module/usr/vo/StaffSearchVO.java
0 → 100644
| 1 | +package com.xly.erp.module.usr.vo; | |
| 2 | + | |
| 3 | +import com.fasterxml.jackson.annotation.JsonProperty; | |
| 4 | + | |
| 5 | +public class StaffSearchVO { | |
| 6 | + | |
| 7 | + @JsonProperty("iIncrement") | |
| 8 | + private Integer iIncrement; | |
| 9 | + | |
| 10 | + @JsonProperty("sStaffNo") | |
| 11 | + private String sStaffNo; | |
| 12 | + | |
| 13 | + @JsonProperty("sStaffName") | |
| 14 | + private String sStaffName; | |
| 15 | + | |
| 16 | + @JsonProperty("sDepartment") | |
| 17 | + private String sDepartment; | |
| 18 | + | |
| 19 | + public Integer getIIncrement() { return iIncrement; } | |
| 20 | + public void setIIncrement(Integer iIncrement) { this.iIncrement = iIncrement; } | |
| 21 | + public String getSStaffNo() { return sStaffNo; } | |
| 22 | + public void setSStaffNo(String sStaffNo) { this.sStaffNo = sStaffNo; } | |
| 23 | + public String getSStaffName() { return sStaffName; } | |
| 24 | + public void setSStaffName(String sStaffName) { this.sStaffName = sStaffName; } | |
| 25 | + public String getSDepartment() { return sDepartment; } | |
| 26 | + public void setSDepartment(String sDepartment) { this.sDepartment = sDepartment; } | |
| 27 | +} | ... | ... |
backend/src/main/java/com/xly/erp/module/usr/vo/UserListVO.java
| ... | ... | @@ -12,6 +12,9 @@ public class UserListVO { |
| 12 | 12 | @JsonProperty("sUserName") |
| 13 | 13 | private String sUserName; |
| 14 | 14 | |
| 15 | + @JsonProperty("iStaffId") | |
| 16 | + private Integer iStaffId; | |
| 17 | + | |
| 15 | 18 | @JsonProperty("staffName") |
| 16 | 19 | private String staffName; |
| 17 | 20 | |
| ... | ... | @@ -43,6 +46,8 @@ public class UserListVO { |
| 43 | 46 | public void setIIncrement(Integer iIncrement) { this.iIncrement = iIncrement; } |
| 44 | 47 | public String getSUserName() { return sUserName; } |
| 45 | 48 | public void setSUserName(String sUserName) { this.sUserName = sUserName; } |
| 49 | + public Integer getIStaffId() { return iStaffId; } | |
| 50 | + public void setIStaffId(Integer iStaffId) { this.iStaffId = iStaffId; } | |
| 46 | 51 | public String getStaffName() { return staffName; } |
| 47 | 52 | public void setStaffName(String staffName) { this.staffName = staffName; } |
| 48 | 53 | public String getSUserNo() { return sUserNo; } | ... | ... |
backend/src/main/resources/mapper/usr/UserMapper.xml
frontend/src/api/staff.ts
0 → 100644
| 1 | +import { request } from "./client"; | |
| 2 | + | |
| 3 | +export interface StaffSearchVO { | |
| 4 | + iIncrement: number; | |
| 5 | + sStaffNo: string; | |
| 6 | + sStaffName: string; | |
| 7 | + sDepartment: string | null; | |
| 8 | +} | |
| 9 | + | |
| 10 | +export function searchStaff(keyword?: string, limit = 20): Promise<StaffSearchVO[]> { | |
| 11 | + return request<StaffSearchVO[]>({ | |
| 12 | + url: "/usr/staffs", | |
| 13 | + method: "GET", | |
| 14 | + params: { keyword: keyword ?? "", limit }, | |
| 15 | + }); | |
| 16 | +} | ... | ... |
frontend/src/api/user.ts
frontend/src/components/StaffPicker.tsx
0 → 100644
| 1 | +import { useEffect, useRef, useState } from "react"; | |
| 2 | +import { SearchOutlined } from "@ant-design/icons"; | |
| 3 | +import { searchStaff, type StaffSearchVO } from "@/api/staff"; | |
| 4 | + | |
| 5 | +interface Props { | |
| 6 | + value: string; | |
| 7 | + staffId: number | null; | |
| 8 | + onChange: (name: string, staffId: number | null) => void; | |
| 9 | + disabled?: boolean; | |
| 10 | + required?: boolean; | |
| 11 | + placeholder?: string; | |
| 12 | +} | |
| 13 | + | |
| 14 | +const fieldControl: React.CSSProperties = { | |
| 15 | + flex: 1, | |
| 16 | + minWidth: 0, | |
| 17 | + height: "var(--input-h)", | |
| 18 | + border: "1px solid var(--border-input)", | |
| 19 | + background: "var(--bg-input)", | |
| 20 | + padding: "0 26px 0 6px", | |
| 21 | + fontSize: 12, | |
| 22 | + color: "var(--text)", | |
| 23 | + borderRadius: 0, | |
| 24 | + outline: "none", | |
| 25 | + fontFamily: "inherit", | |
| 26 | +}; | |
| 27 | + | |
| 28 | +export default function StaffPicker({ | |
| 29 | + value, | |
| 30 | + staffId, | |
| 31 | + onChange, | |
| 32 | + disabled, | |
| 33 | + required, | |
| 34 | + placeholder, | |
| 35 | +}: Props) { | |
| 36 | + const [open, setOpen] = useState(false); | |
| 37 | + const [items, setItems] = useState<StaffSearchVO[]>([]); | |
| 38 | + const [loading, setLoading] = useState(false); | |
| 39 | + const wrapRef = useRef<HTMLDivElement>(null); | |
| 40 | + const debounce = useRef<number | null>(null); | |
| 41 | + | |
| 42 | + useEffect(() => { | |
| 43 | + function onDocClick(e: MouseEvent) { | |
| 44 | + if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) { | |
| 45 | + setOpen(false); | |
| 46 | + } | |
| 47 | + } | |
| 48 | + document.addEventListener("mousedown", onDocClick); | |
| 49 | + return () => document.removeEventListener("mousedown", onDocClick); | |
| 50 | + }, []); | |
| 51 | + | |
| 52 | + const fetchSuggestions = async (kw: string) => { | |
| 53 | + setLoading(true); | |
| 54 | + try { | |
| 55 | + const list = await searchStaff(kw, 20); | |
| 56 | + setItems(list); | |
| 57 | + } catch { | |
| 58 | + setItems([]); | |
| 59 | + } finally { | |
| 60 | + setLoading(false); | |
| 61 | + } | |
| 62 | + }; | |
| 63 | + | |
| 64 | + const onFocus = () => { | |
| 65 | + if (disabled) return; | |
| 66 | + setOpen(true); | |
| 67 | + void fetchSuggestions(value); | |
| 68 | + }; | |
| 69 | + | |
| 70 | + const onInputChange = (next: string) => { | |
| 71 | + // Typing clears any previously bound staff id; user must re-select | |
| 72 | + onChange(next, null); | |
| 73 | + setOpen(true); | |
| 74 | + if (debounce.current) window.clearTimeout(debounce.current); | |
| 75 | + debounce.current = window.setTimeout(() => fetchSuggestions(next), 200); | |
| 76 | + }; | |
| 77 | + | |
| 78 | + const select = (s: StaffSearchVO) => { | |
| 79 | + onChange(s.sStaffName, s.iIncrement); | |
| 80 | + setOpen(false); | |
| 81 | + }; | |
| 82 | + | |
| 83 | + return ( | |
| 84 | + <div | |
| 85 | + ref={wrapRef} | |
| 86 | + style={{ position: "relative", flex: 1, minWidth: 0, display: "flex", alignItems: "center" }} | |
| 87 | + > | |
| 88 | + <input | |
| 89 | + type="text" | |
| 90 | + value={value} | |
| 91 | + onChange={(e) => onInputChange(e.target.value)} | |
| 92 | + onFocus={onFocus} | |
| 93 | + disabled={disabled} | |
| 94 | + placeholder={placeholder} | |
| 95 | + style={{ | |
| 96 | + ...fieldControl, | |
| 97 | + ...(disabled ? { background: "var(--bg-disabled)", color: "var(--text-muted)" } : {}), | |
| 98 | + ...(required && !disabled ? { background: "#d4e8f7" } : {}), | |
| 99 | + }} | |
| 100 | + /> | |
| 101 | + <span | |
| 102 | + style={{ | |
| 103 | + position: "absolute", | |
| 104 | + right: 6, | |
| 105 | + top: "50%", | |
| 106 | + transform: "translateY(-50%)", | |
| 107 | + color: "var(--text-faint)", | |
| 108 | + pointerEvents: "none", | |
| 109 | + fontSize: 12, | |
| 110 | + }} | |
| 111 | + > | |
| 112 | + <SearchOutlined /> | |
| 113 | + </span> | |
| 114 | + {staffId != null && !disabled && ( | |
| 115 | + <span | |
| 116 | + style={{ | |
| 117 | + position: "absolute", | |
| 118 | + right: 22, | |
| 119 | + top: "50%", | |
| 120 | + transform: "translateY(-50%)", | |
| 121 | + fontSize: 9, | |
| 122 | + color: "#fff", | |
| 123 | + background: "var(--success)", | |
| 124 | + padding: "1px 4px", | |
| 125 | + borderRadius: 2, | |
| 126 | + pointerEvents: "none", | |
| 127 | + }} | |
| 128 | + title={`已绑定职员 ID ${staffId}`} | |
| 129 | + > | |
| 130 | + 已绑定 | |
| 131 | + </span> | |
| 132 | + )} | |
| 133 | + {open && !disabled && ( | |
| 134 | + <div | |
| 135 | + style={{ | |
| 136 | + position: "absolute", | |
| 137 | + top: "calc(100% + 1px)", | |
| 138 | + left: 0, | |
| 139 | + right: 0, | |
| 140 | + zIndex: 50, | |
| 141 | + background: "#fff", | |
| 142 | + border: "1px solid var(--border-input)", | |
| 143 | + boxShadow: "0 2px 8px rgba(0,0,0,0.08)", | |
| 144 | + maxHeight: 240, | |
| 145 | + overflow: "auto", | |
| 146 | + }} | |
| 147 | + > | |
| 148 | + {loading && ( | |
| 149 | + <div style={{ padding: "8px 12px", fontSize: 12, color: "var(--text-faint)" }}> | |
| 150 | + 加载中… | |
| 151 | + </div> | |
| 152 | + )} | |
| 153 | + {!loading && items.length === 0 && ( | |
| 154 | + <div style={{ padding: "8px 12px", fontSize: 12, color: "var(--text-faint)" }}> | |
| 155 | + 没有匹配的职员 | |
| 156 | + </div> | |
| 157 | + )} | |
| 158 | + {!loading && | |
| 159 | + items.map((s) => ( | |
| 160 | + <div | |
| 161 | + key={s.iIncrement} | |
| 162 | + onMouseDown={(e) => { | |
| 163 | + e.preventDefault(); | |
| 164 | + select(s); | |
| 165 | + }} | |
| 166 | + style={{ | |
| 167 | + padding: "6px 12px", | |
| 168 | + fontSize: 12, | |
| 169 | + cursor: "pointer", | |
| 170 | + display: "flex", | |
| 171 | + alignItems: "center", | |
| 172 | + gap: 8, | |
| 173 | + borderBottom: "1px solid #f0f2f5", | |
| 174 | + }} | |
| 175 | + onMouseEnter={(e) => { | |
| 176 | + e.currentTarget.style.background = "var(--bg-row-hover)"; | |
| 177 | + }} | |
| 178 | + onMouseLeave={(e) => { | |
| 179 | + e.currentTarget.style.background = "transparent"; | |
| 180 | + }} | |
| 181 | + > | |
| 182 | + <span style={{ color: "var(--text)" }}>{s.sStaffName}</span> | |
| 183 | + <span style={{ color: "var(--text-faint)", fontSize: 11 }} className="mono"> | |
| 184 | + {s.sStaffNo} | |
| 185 | + </span> | |
| 186 | + <span style={{ flex: 1 }} /> | |
| 187 | + <span style={{ color: "var(--text-muted)", fontSize: 11 }}> | |
| 188 | + {s.sDepartment ?? ""} | |
| 189 | + </span> | |
| 190 | + </div> | |
| 191 | + ))} | |
| 192 | + </div> | |
| 193 | + )} | |
| 194 | + </div> | |
| 195 | + ); | |
| 196 | +} | ... | ... |
frontend/src/pages/usr/UserDetail.tsx
| ... | ... | @@ -19,6 +19,7 @@ import { |
| 19 | 19 | PrimCheckbox, |
| 20 | 20 | ToolbarBtnDark, |
| 21 | 21 | } from "@/components/Primitives"; |
| 22 | +import StaffPicker from "@/components/StaffPicker"; | |
| 22 | 23 | import { createUser, updateUser, type UserDTO } from "@/api/user"; |
| 23 | 24 | import { |
| 24 | 25 | USER_TYPES, |
| ... | ... | @@ -41,6 +42,7 @@ interface Props { |
| 41 | 42 | interface FormState { |
| 42 | 43 | sUserNo: string; |
| 43 | 44 | sUserName: string; |
| 45 | + iStaffId: number | null; | |
| 44 | 46 | staffName: string; |
| 45 | 47 | department: string; |
| 46 | 48 | sUserType: string; |
| ... | ... | @@ -69,6 +71,7 @@ export default function UserDetail({ userId, mode: initialMode }: Props) { |
| 69 | 71 | | { |
| 70 | 72 | sUserNo: string; |
| 71 | 73 | sUserName: string; |
| 74 | + iStaffId: number | null; | |
| 72 | 75 | staffName: string | null; |
| 73 | 76 | department: string | null; |
| 74 | 77 | sUserType: string; |
| ... | ... | @@ -84,6 +87,7 @@ export default function UserDetail({ userId, mode: initialMode }: Props) { |
| 84 | 87 | ? { |
| 85 | 88 | sUserNo: "", |
| 86 | 89 | sUserName: "", |
| 90 | + iStaffId: null, | |
| 87 | 91 | staffName: "", |
| 88 | 92 | department: "", |
| 89 | 93 | sUserType: "普通用户", |
| ... | ... | @@ -93,6 +97,7 @@ export default function UserDetail({ userId, mode: initialMode }: Props) { |
| 93 | 97 | : { |
| 94 | 98 | sUserNo: snapshot.sUserNo, |
| 95 | 99 | sUserName: snapshot.sUserName, |
| 100 | + iStaffId: snapshot.iStaffId, | |
| 96 | 101 | staffName: snapshot.staffName ?? "", |
| 97 | 102 | department: snapshot.department ?? "", |
| 98 | 103 | sUserType: snapshot.sUserType, |
| ... | ... | @@ -139,10 +144,10 @@ export default function UserDetail({ userId, mode: initialMode }: Props) { |
| 139 | 144 | const dto: UserDTO = { |
| 140 | 145 | sUserNo: form.sUserNo, |
| 141 | 146 | sUserName: form.sUserName, |
| 147 | + iStaffId: form.iStaffId, | |
| 142 | 148 | sUserType: form.sUserType, |
| 143 | 149 | sLanguage: form.sLanguage, |
| 144 | 150 | bCanModifyDocs: form.bCanModifyDocs, |
| 145 | - // iStaffId omitted: prototype has no staff picker. | |
| 146 | 151 | // permissionCategoryIds omitted: prototype permissions are by name; backend wants IDs. |
| 147 | 152 | }; |
| 148 | 153 | setSubmitting(true); |
| ... | ... | @@ -297,12 +302,15 @@ export default function UserDetail({ userId, mode: initialMode }: Props) { |
| 297 | 302 | /> |
| 298 | 303 | </Field> |
| 299 | 304 | <Field label="员工名" required> |
| 300 | - <PrimInput | |
| 305 | + <StaffPicker | |
| 301 | 306 | value={form.staffName} |
| 302 | - onChange={(v) => set("staffName", v)} | |
| 307 | + staffId={form.iStaffId} | |
| 308 | + onChange={(name, id) => | |
| 309 | + setForm((s) => ({ ...s, staffName: name, iStaffId: id })) | |
| 310 | + } | |
| 303 | 311 | disabled={disabled} |
| 304 | 312 | required |
| 305 | - placeholder="(关联职员)" | |
| 313 | + placeholder="输入员工姓名搜索" | |
| 306 | 314 | /> |
| 307 | 315 | </Field> |
| 308 | 316 | ... | ... |