人员岗位补负责人/线路维护,并同步后台人员、机器人回复与看板页改动。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
linmianxun
2026-09-15 11:12:13 +08:00
co-authored by Cursor
parent a7a8487eb3
commit 58b3c2f3e6
7 changed files with 425 additions and 155 deletions
@@ -2,7 +2,8 @@
/**
* 员工管理:对接主账 /inquiry/staffRole。
* 企微 userid 可手工维护,也可点「同步企业微信」由主账 upsert(姓名/手机/部门)。
* 浏览器禁止直连企微;岗位/线路仍手工改。
* 浏览器禁止直连企微;岗位/负责人/线路仍手工改。
* 产品·航线:负责人(是/否)+ 负责线路多选,选项照搬旧项目 admin-staff。
*/
import { computed, onMounted, reactive, ref, watch } from "vue";
import { message, Modal } from "ant-design-vue";
@@ -33,6 +34,8 @@ type StaffRow = {
role: StaffRole;
roleLabel: string;
routes: string;
/** 产品/航线是否负责人;销售为 null */
isLeader: boolean | null;
notifyEventException: boolean;
createdAt: string;
status: "active" | "disabled";
@@ -48,6 +51,48 @@ const roleLabelMap: Record<StaffRole, string> = {
land: "产品·陆运",
};
/** 按岗位的负责线路选项(与原型 admin-staff.html ROUTES_BY_ROLE 一致) */
const ROUTES_BY_ROLE: Record<Exclude<StaffRole, "sales">, string[]> = {
air: [
"CGO-KUL",
"SZX-KUL",
"HKG-KUL",
"NKG-KUL",
"HKG-DEL",
"CGO-MAA",
"CGO-BLR",
"ZUH-CRK",
"WNZ-CGK",
"BKK",
"外采-空运",
"全线",
],
sea: ["海运全线"],
land: ["中港/中亚/中欧", "国内长途/零担", "国内城配/拖车/打包", "东南亚"],
};
const ALL_ROUTE_FILTER_OPTIONS = [
...ROUTES_BY_ROLE.air,
...ROUTES_BY_ROLE.sea,
...ROUTES_BY_ROLE.land,
];
function isProductOrAir(role: StaffRole) {
return role === "air" || role === "sea" || role === "land";
}
function parseRouteList(raw: string): string[] {
if (!raw || raw === "-") return [];
return raw
.split(/[/·,、]/)
.map((s) => s.trim())
.filter(Boolean);
}
function joinRoutes(list: string[]) {
return list.map((s) => s.trim()).filter(Boolean).join(",");
}
const rows = ref<StaffRow[]>([]);
const loading = ref(false);
const total = ref(0);
@@ -76,14 +121,32 @@ const form = reactive({
phone: "",
dept: "",
role: "sales" as StaffRole,
routes: "",
/** 空字符串=未选;true/false 字符串对齐旧项目下拉 */
isLeader: "" as "" | "true" | "false",
/** 负责线路多选;销售岗位不展示 */
routeList: [] as string[],
notifyEventException: false,
status: "active" as StaffRow["status"],
});
/** 当前岗位可选线路;销售为空 */
const routeOptions = computed(() => {
if (!isProductOrAir(form.role)) return [] as string[];
return ROUTES_BY_ROLE[form.role];
});
const showLeaderAndRoutes = computed(() => isProductOrAir(form.role));
const filtered = computed(() => rows.value);
/**
* 部门下拉过滤:点开/聚焦时展示全量树;只有用户继续打字才按关键字筛。
* 为何:编辑时 form.dept 已是「武汉友通达」等完整名,若直接用它过滤会只剩 1 条。
*/
const deptFilterActive = ref(false);
const filteredDeptOptions = computed(() => {
if (!deptFilterActive.value) return deptOptions.value;
const q = (form.dept || "").trim().toLowerCase();
if (!q) return deptOptions.value;
return deptOptions.value.filter((d) => d.name.toLowerCase().includes(q));
@@ -97,6 +160,9 @@ function colorFor(name: string) {
function mapRow(r: any): StaffRow {
const role = (r.roleCode || "sales") as StaffRole;
let isLeader: boolean | null = null;
if (r.isLeader === 1 || r.isLeader === true) isLeader = true;
else if (r.isLeader === 0 || r.isLeader === false) isLeader = false;
return {
id: String(r.id || ""),
name: r.name || "",
@@ -106,6 +172,7 @@ function mapRow(r: any): StaffRow {
role: ["sales", "air", "sea", "land"].includes(role) ? role : "sales",
roleLabel: r.roleLabel || roleLabelMap[role] || role,
routes: r.routes || "-",
isLeader,
notifyEventException: r.notifyEventException === 1 || r.notifyEventException === true,
createdAt: (r.createTime || "").toString().slice(0, 10) || "—",
status: r.status === "disabled" ? "disabled" : "active",
@@ -264,6 +331,7 @@ let deptBlurTimer: ReturnType<typeof setTimeout> | null = null;
function pickDept(d: DeptOption) {
form.dept = d.name;
deptFilterActive.value = false;
deptOpen.value = false;
}
@@ -272,20 +340,45 @@ function onDeptFocus() {
clearTimeout(deptBlurTimer);
deptBlurTimer = null;
}
// 聚焦先看全量,避免当前部门名把列表滤成一项
deptFilterActive.value = false;
deptOpen.value = true;
ensureDeptOptions();
}
function onDeptInput() {
deptFilterActive.value = true;
deptOpen.value = true;
}
function onDeptBlur() {
deptBlurTimer = setTimeout(() => {
deptOpen.value = false;
deptFilterActive.value = false;
}, 180);
}
watch(
() => modalOpen.value,
(open) => {
if (!open) deptOpen.value = false;
if (!open) {
deptOpen.value = false;
deptFilterActive.value = false;
}
},
);
/** 换岗位时:销售清空负责人/线路;产品航线只保留本岗可选线路 */
watch(
() => form.role,
(role) => {
if (!isProductOrAir(role)) {
form.isLeader = "";
form.routeList = [];
return;
}
const valid = new Set(ROUTES_BY_ROLE[role]);
form.routeList = form.routeList.filter((r) => valid.has(r));
},
);
@@ -297,7 +390,8 @@ function openCreate() {
phone: "",
dept: "",
role: "sales",
routes: "",
isLeader: "",
routeList: [],
notifyEventException: false,
status: "active",
});
@@ -308,13 +402,16 @@ function openCreate() {
function openEdit(row: StaffRow) {
editing.value = row;
const valid = isProductOrAir(row.role) ? new Set(ROUTES_BY_ROLE[row.role]) : null;
const routes = parseRouteList(row.routes).filter((r) => (valid ? valid.has(r) : true));
Object.assign(form, {
name: row.name,
wecomId: row.wecomId,
phone: row.phoneMasked === "未填写" ? "" : row.phoneMasked,
dept: row.dept === "未分配" ? "" : row.dept,
role: row.role,
routes: row.routes === "-" ? "" : row.routes,
isLeader: row.isLeader === true ? "true" : row.isLeader === false ? "false" : "",
routeList: routes,
notifyEventException: Boolean(row.notifyEventException),
status: row.status,
});
@@ -328,6 +425,16 @@ async function saveStaff() {
message.warning("请填写姓名与企业微信 ID");
return;
}
if (isProductOrAir(form.role)) {
if (form.isLeader !== "true" && form.isLeader !== "false") {
message.warning("请选择是否负责人");
return;
}
if (!form.routeList.length) {
message.warning("请选择负责线路");
return;
}
}
const body = {
id: editing.value?.id,
name: form.name.trim(),
@@ -336,7 +443,8 @@ async function saveStaff() {
departName: form.dept.trim() || undefined,
roleCode: form.role,
roleLabel: roleLabelMap[form.role],
routes: form.routes.trim() || undefined,
routes: isProductOrAir(form.role) ? joinRoutes(form.routeList) : undefined,
isLeader: isProductOrAir(form.role) ? (form.isLeader === "true" ? 1 : 0) : null,
notifyEventException: form.notifyEventException ? 1 : 0,
status: form.status,
};
@@ -383,11 +491,11 @@ async function syncWecom() {
}
function routeTags(routes: string) {
if (!routes || routes === "-") return [];
return routes
.split(/[/·,、]/)
.map((s) => s.trim())
.filter(Boolean);
return parseRouteList(routes);
}
function filterRouteOption(input: string, option: any) {
return String(option?.label ?? "").includes(input);
}
</script>
@@ -438,15 +546,7 @@ function routeTags(routes: string) {
</select>
<select v-model="filters.route">
<option value="">负责线路</option>
<option value="CGO-KUL">CGO-KUL</option>
<option value="HKG-KUL">HKG-KUL</option>
<option value="全线">全线</option>
<option value="外采-空运">外采-空运</option>
<option value="海运全线">海运全线</option>
<option value="中港/中亚/中欧">中港/中亚/中欧</option>
<option value="国内长途/零担">国内长途/零担</option>
<option value="国内城配/拖车/打包">国内城配/拖车/打包</option>
<option value="东南亚">东南亚</option>
<option v-for="r in ALL_ROUTE_FILTER_OPTIONS" :key="r" :value="r">{{ r }}</option>
</select>
<select v-model="filters.status">
<option value="">状态</option>
@@ -558,7 +658,7 @@ function routeTags(routes: string) {
:disabled="deptLoading"
:placeholder="deptLoading ? '加载部门…' : '请选择或输入部门'"
@focus="onDeptFocus"
@input="deptOpen = true"
@input="onDeptInput"
@blur="onDeptBlur"
/>
<span class="dept-combo-arrow" aria-hidden="true">▾</span>
@@ -585,9 +685,13 @@ function routeTags(routes: string) {
<option value="land">产品·陆运</option>
</select>
</div>
<div class="form-row">
<label class="lbl">负责线路</label>
<input v-model="form.routes" class="field" type="text" placeholder="销售可留空" />
<div v-if="showLeaderAndRoutes" class="form-row">
<label class="lbl">负责人 <span class="req">*</span></label>
<select v-model="form.isLeader" class="field">
<option value="">请选择是否负责人</option>
<option value="true">是</option>
<option value="false">否</option>
</select>
</div>
<div class="form-row">
<label class="lbl">消息提醒</label>
@@ -611,6 +715,19 @@ function routeTags(routes: string) {
</button>
</div>
</div>
<div v-if="showLeaderAndRoutes" class="form-row">
<label class="lbl">负责线路 <span class="req">*</span></label>
<a-select
v-model:value="form.routeList"
mode="multiple"
allow-clear
show-search
class="staff-route-select"
placeholder="请选择负责线路,可多选"
:options="routeOptions.map((r) => ({ label: r, value: r }))"
:filter-option="filterRouteOption"
/>
</div>
</AdminModal>
</template>
@@ -698,4 +815,16 @@ function routeTags(routes: string) {
color: #94a3b8;
cursor: default;
}
.staff-route-select {
width: 100%;
}
.staff-route-select :deep(.ant-select-selector) {
min-height: 34px;
border-radius: 7px !important;
border-color: #cbd5e1 !important;
font-size: 11px;
font-weight: 600;
}
</style>