人员所属部门下拉对齐旧项目:主账提供 /departments 短名全量,后台可搜可选。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
linmianxun
2026-09-14 21:13:52 +08:00
co-authored by Cursor
parent 624ec1b7c4
commit 38a25a4b40
4 changed files with 244 additions and 76 deletions
@@ -22,8 +22,10 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import javax.servlet.http.HttpServletRequest;
import java.util.ArrayList;
import java.util.Date;
import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
@@ -147,9 +149,40 @@ public class InquiryStaffRoleController extends JeecgController<InquiryStaffRole
}
/**
* 企微部门下拉选项。浏览器禁止直连企微,只打本接口。
* 员工「所属部门」下拉(对齐旧项目 admin-rel26)。
* <p>
* 旧前端:{@code GET /inquiry/staffRole/departments} → {@code string[]},
* 再 {@code options: names.map(n => ({label:n,value:n}))},showSearch 可滚动全量。
* 本接口同源:企微 {@code department/list} 短名全量;根公司(id=1)不进下拉。
* 浏览器禁止直连企微。
*/
@ApiOperation("企微部门列表")
@ApiOperation("员工部门名列表(对齐旧项目 /departments)")
@RequiresPermissions("inquiry:staff:list")
@GetMapping("/departments")
public Result<List<String>> departments() {
List<WeComDepartment> all = weComAddressBookClient.listDepartments();
List<String> names = new ArrayList<>();
LinkedHashSet<String> seen = new LinkedHashSet<>();
for (WeComDepartment d : all) {
if (d == null || StringUtils.isBlank(d.getName())) {
continue;
}
// 企微根部门(id=1 / parentid=0)是公司全称,员工下拉不展示
if (d.getId() == 1 || d.getParentId() <= 0) {
continue;
}
String name = d.getName().trim();
if (seen.add(name)) {
names.add(name);
}
}
return Result.OK(names);
}
/**
* 企微部门结构列表(含 id/path,供排查)。员工下拉请用 {@link #departments()}。
*/
@ApiOperation("企微部门结构列表")
@RequiresPermissions("inquiry:staff:list")
@GetMapping("/wecomDepartments")
public Result<List<WeComDepartment>> wecomDepartments() {
@@ -127,8 +127,9 @@ public class WeComAddressBookClient {
}
/**
* 拉取企微部门树(扁平列表,带 path 便于下拉展示)。
* 拉取企微 {@code department/list} 全量扁平列表(短名 + path)。
* <p>
* 员工下拉与旧项目同源:整份通讯录,不截层级。顺序保持企微返回序。
* 副作用:出站 HTTP;失败抛 IllegalStateException。
*/
public List<WeComDepartment> listDepartments() {
@@ -163,8 +164,8 @@ public class WeComAddressBookClient {
String path = buildDepartmentPath(id, byId);
result.add(new WeComDepartment(id, name, parentId, path));
}
// 按路径排序,下拉更易扫
result.sort((a, b) -> StringUtils.defaultString(a.getPath()).compareTo(StringUtils.defaultString(b.getPath())));
// 保持企微 department/list 原序(LinkedHashMap 插入序 = 接口返回序),
// 不要按 path 重排,也不要截层级;员工下拉与旧项目一样用这份全量短名。
return result;
}
@@ -9,6 +9,7 @@ import { message, Modal } from "ant-design-vue";
import {
disableStaff,
getStaffCounts,
getStaffDepartments,
getStaffList,
getWecomDepartments,
saveStaff as saveStaffApi,
@@ -19,7 +20,8 @@ import AdminModal from "/@/views/inquiry/components/AdminModal.vue";
type StaffRole = "sales" | "air" | "sea" | "land";
type WecomDeptOption = { id: number; name: string; path: string; parentId: number; depth: number };
/** 部门下拉:value 存短名,label 带层级缩进 */
type DeptOption = { id: number; name: string; label: string; depth: number };
type StaffRow = {
id: string;
@@ -59,9 +61,11 @@ const filters = reactive({
const modalOpen = ref(false);
const editing = ref<StaffRow | null>(null);
const syncing = ref(false);
const wecomDepts = ref<WecomDeptOption[]>([]);
const wecomDeptsLoading = ref(false);
const wecomDeptsLoaded = ref(false);
/** 部门树扁平列表:一级不缩进,子级前加空格 */
const deptOptions = ref<DeptOption[]>([]);
const deptLoading = ref(false);
const deptLoaded = ref(false);
const deptOpen = ref(false);
const form = reactive({
name: "",
wecomId: "",
@@ -75,6 +79,12 @@ const form = reactive({
const filtered = computed(() => rows.value);
const filteredDeptOptions = computed(() => {
const q = (form.dept || "").trim().toLowerCase();
if (!q) return deptOptions.value;
return deptOptions.value.filter((d) => d.name.toLowerCase().includes(q));
});
function colorFor(name: string) {
let h = 0;
for (let i = 0; i < name.length; i++) h = (h + name.charCodeAt(i) * (i + 1)) % AVATAR_COLORS.length;
@@ -148,64 +158,79 @@ watch(
onMounted(() => loadList());
/** 企微根部门公司全称,下拉不展示 */
const ROOT_DEPT_NAME = "深圳市友通达供应链管理有限公司";
/**
* 打开弹窗时拉企微部门;失败不挡手工保存,仅提示。
* 下拉对齐旧项目业务层:只展示「分公司」「旧组织」下的直接子部门
*(事业二部/元昇/武汉友通达/通多邦…),不要公司根下的一级中心。
* 按 parentId 排成树再前序展开。
* 展示:部门1 / 「 」子部门1 / 「 」子部门2 / 部门2(根公司不出现)。
* 保存仍用短名 name。
*/
async function ensureWecomDepartments() {
if (wecomDeptsLoaded.value || wecomDeptsLoading.value) {
function buildDeptTreeOptions(raw: any[]): DeptOption[] {
const nodes = (raw as any[])
.map((d) => ({
id: Number(d?.id),
name: String(d?.name || "").trim(),
parentId: Number(d?.parentId ?? d?.parentid ?? 0),
}))
.filter((d) => d.name && Number.isFinite(d.id) && d.id > 0)
.filter((d) => d.id !== 1 && d.parentId > 0 && d.name !== ROOT_DEPT_NAME);
const idSet = new Set(nodes.map((n) => n.id));
const children = new Map<number, typeof nodes>();
for (const n of nodes) {
const pid = idSet.has(n.parentId) ? n.parentId : 0;
const list = children.get(pid) || [];
list.push(n);
children.set(pid, list);
}
const out: DeptOption[] = [];
const walk = (pid: number, depth: number) => {
for (const n of children.get(pid) || []) {
out.push({
id: n.id,
name: n.name,
label: "\u00A0".repeat(depth) + n.name,
depth,
});
walk(n.id, depth + 1);
}
};
walk(0, 0);
return out;
}
/**
* 打开弹窗拉企微部门树;失败不挡手工保存。
* 优先 wecomDepartments(有 parentId 才能缩进);否则退回 departments 扁平短名。
*/
async function ensureDeptOptions() {
if (deptLoaded.value || deptLoading.value) {
ensureCurrentDeptOption();
return;
}
wecomDeptsLoading.value = true;
deptLoading.value = true;
try {
const res: any = await getWecomDepartments();
const list = Array.isArray(res) ? res : res?.result || [];
const all: WecomDeptOption[] = (list as any[])
.map((d) => {
const path = String(d.path || d.name || "");
const name = String(d.name || path);
const parentId = Number(d.parentId ?? d.parentid ?? 0);
const depth = path ? path.split(" / ").length - 1 : 0;
return {
id: Number(d.id),
name,
path,
parentId,
depth: depth < 0 ? 0 : depth,
};
})
.filter((d) => d.name && d.id);
// 旧项目下拉对应层:分公司 + 旧组织 的子节点
const businessParentNames = new Set(["分公司", "旧组织"]);
const businessParentIds = new Set(
all.filter((d) => businessParentNames.has(d.name)).map((d) => d.id),
);
let picked = all.filter((d) => businessParentIds.has(d.parentId));
// 兜底:若企微改名导致找不到父节点,仍回退到根下一级,避免空下拉
if (!picked.length) {
const rootIds = new Set(
all.filter((d) => d.parentId === 0 || d.depth === 0).map((d) => d.id),
);
if (!rootIds.size) {
const minDepth = Math.min(...all.map((d) => d.depth));
all.filter((d) => d.depth === minDepth).forEach((d) => rootIds.add(d.id));
}
picked = all.filter((d) => rootIds.has(d.parentId));
try {
const res: any = await getWecomDepartments();
const list = Array.isArray(res) ? res : res?.result || [];
deptOptions.value = buildDeptTreeOptions(list);
} catch {
const res: any = await getStaffDepartments();
const list = Array.isArray(res) ? res : res?.result || [];
deptOptions.value = (list as any[])
.map((x, i) => {
const name = String(x || "").trim();
return { id: -(i + 1), name, label: name, depth: 0 };
})
.filter((d) => d.name && d.name !== ROOT_DEPT_NAME);
}
wecomDepts.value = picked
.slice()
.sort((a, b) => a.name.localeCompare(b.name, "zh-CN"))
.map((d) => ({ ...d, depth: 0 }));
wecomDeptsLoaded.value = true;
deptLoaded.value = true;
} catch (e: any) {
message.warning(e?.message || "企微部门列表加载失败,可稍后重试");
message.warning(e?.message || "部门列表加载失败,可稍后重试");
} finally {
wecomDeptsLoading.value = false;
deptLoading.value = false;
ensureCurrentDeptOption();
}
}
@@ -213,17 +238,40 @@ async function ensureWecomDepartments() {
function ensureCurrentDeptOption() {
const cur = (form.dept || "").trim();
if (!cur) return;
const hit = wecomDepts.value.some((d) => d.name === cur || d.path === cur);
if (!hit) {
wecomDepts.value = [{ id: -1, name: cur, path: cur, parentId: 0, depth: 0 }, ...wecomDepts.value];
} else {
const byPath = wecomDepts.value.find((d) => d.path === cur);
if (byPath && form.dept !== byPath.name) {
form.dept = byPath.name;
}
if (!deptOptions.value.some((d) => d.name === cur)) {
deptOptions.value = [{ id: -1, name: cur, label: cur, depth: 0 }, ...deptOptions.value];
}
}
let deptBlurTimer: ReturnType<typeof setTimeout> | null = null;
function pickDept(d: DeptOption) {
form.dept = d.name;
deptOpen.value = false;
}
function onDeptFocus() {
if (deptBlurTimer) {
clearTimeout(deptBlurTimer);
deptBlurTimer = null;
}
deptOpen.value = true;
ensureDeptOptions();
}
function onDeptBlur() {
deptBlurTimer = setTimeout(() => {
deptOpen.value = false;
}, 180);
}
watch(
() => modalOpen.value,
(open) => {
if (!open) deptOpen.value = false;
},
);
function openCreate() {
editing.value = null;
Object.assign(form, {
@@ -237,9 +285,8 @@ function openCreate() {
status: "active",
});
modalOpen.value = true;
// 每次打开重新拉一级部门,避免会话里还留着旧的「整棵树」缓存
wecomDeptsLoaded.value = false;
ensureWecomDepartments();
deptLoaded.value = false;
ensureDeptOptions();
}
function openEdit(row: StaffRow) {
@@ -255,8 +302,8 @@ function openEdit(row: StaffRow) {
status: row.status,
});
modalOpen.value = true;
wecomDeptsLoaded.value = false;
ensureWecomDepartments();
deptLoaded.value = false;
ensureDeptOptions();
}
async function saveStaff() {
@@ -483,10 +530,32 @@ function routeTags(routes: string) {
</div>
<div class="form-row">
<label class="lbl">所属部门</label>
<select v-model="form.dept" class="field" :disabled="wecomDeptsLoading">
<option value="">{{ wecomDeptsLoading ? "加载企微部门…" : "请选择部门" }}</option>
<option v-for="d in wecomDepts" :key="d.id + '-' + d.name" :value="d.name">{{ d.name }}</option>
</select>
<div class="dept-combo">
<input
v-model="form.dept"
class="field"
type="text"
autocomplete="off"
:disabled="deptLoading"
:placeholder="deptLoading ? '加载部门…' : '请选择或输入部门'"
@focus="onDeptFocus"
@input="deptOpen = true"
@blur="onDeptBlur"
/>
<span class="dept-combo-arrow" aria-hidden="true">▾</span>
<ul v-show="deptOpen" class="dept-combo-list">
<li v-if="!filteredDeptOptions.length" class="is-empty">无匹配部门</li>
<li
v-for="d in filteredDeptOptions"
:key="d.id"
:class="{ 'is-active': d.name === form.dept }"
:style="{ paddingLeft: 10 + d.depth * 12 + 'px' }"
@mousedown.prevent="pickDept(d)"
>
{{ d.name }}
</li>
</ul>
</div>
</div>
<div class="form-row">
<label class="lbl">岗位 <span class="req">*</span></label>
@@ -553,4 +622,61 @@ function routeTags(routes: string) {
accent-color: #155eef;
cursor: pointer;
}
.dept-combo {
position: relative;
width: 100%;
}
.dept-combo .field {
padding-right: 28px;
}
.dept-combo-arrow {
position: absolute;
right: 10px;
top: 50%;
transform: translateY(-50%);
pointer-events: none;
color: #64748b;
font-size: 10px;
line-height: 1;
}
.dept-combo-list {
position: absolute;
left: 0;
right: 0;
top: calc(100% + 4px);
z-index: 30;
max-height: 220px;
overflow: auto;
margin: 0;
padding: 4px 0;
list-style: none;
background: #fff;
border: 1px solid #cbd5e1;
border-radius: 7px;
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.12);
}
.dept-combo-list li {
padding: 6px 10px;
font-size: 11px;
font-weight: 600;
color: #172033;
cursor: pointer;
white-space: nowrap;
}
.dept-combo-list li:hover,
.dept-combo-list li.is-active {
background: #eff6ff;
color: #155eef;
}
.dept-combo-list li.is-empty {
color: #94a3b8;
cursor: default;
}
</style>
@@ -1,6 +1,7 @@
/**
* 员工岗位 API:对接主账 /inquiry/staffRole。
* 与 Jeecg /sys/user 分离——本页管企微岗位绑定;同步走主账 /syncWecom,浏览器不直连企微。
* 部门下拉对齐旧项目:GET /inquiry/staffRole/departments → string[]。
*/
import { defHttp } from '/@/utils/http/axios';
@@ -12,6 +13,8 @@ enum Api {
disable = '/inquiry/staffRole/disable',
queryById = '/inquiry/staffRole/queryById',
syncWecom = '/inquiry/staffRole/syncWecom',
/** 旧项目同名接口:部门短名全量 */
departments = '/inquiry/staffRole/departments',
wecomDepartments = '/inquiry/staffRole/wecomDepartments',
}
@@ -23,5 +26,10 @@ export const disableStaff = (id: string) => defHttp.post({ url: Api.disable, par
export const getStaffById = (id: string) => defHttp.get({ url: Api.queryById, params: { id } });
/** 主账拉企微通讯录 upsert;返回 { fetched, created, updated, skipped, message } */
export const syncWecomStaff = () => defHttp.post({ url: Api.syncWecom });
/** 企微部门下拉(主账转发,禁止浏览器直连企微) */
/**
* 部门下拉(对齐旧项目):返回 string[] 短名全量,可搜索可滚动。
* 失败时勿挡手工填部门。
*/
export const getStaffDepartments = () => defHttp.get({ url: Api.departments });
/** @deprecated 结构列表;员工下拉请用 getStaffDepartments */
export const getWecomDepartments = () => defHttp.get({ url: Api.wecomDepartments });