Commit 3beaa61ae870b4f7435bede82d23a4cdca84c5c9

Authored by zichun
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)
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
... ... @@ -6,6 +6,7 @@
6 6 <sql id="baseSelectColumns">
7 7 u.iIncrement AS iIncrement,
8 8 u.sUserName AS sUserName,
  9 + u.iStaffId AS iStaffId,
9 10 s.sStaffName AS staffName,
10 11 u.sUserNo AS sUserNo,
11 12 s.sDepartment AS department,
... ...
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
... ... @@ -3,6 +3,7 @@ import { request } from &quot;./client&quot;;
3 3 export interface UserListVO {
4 4 iIncrement: number;
5 5 sUserName: string;
  6 + iStaffId: number | null;
6 7 staffName: string | null;
7 8 sUserNo: string;
8 9 department: string | null;
... ...
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  
... ...