814 lines
24 KiB
Vue
814 lines
24 KiB
Vue
<script setup lang="ts">
|
||
/**
|
||
* 员工管理:对接主账 /inquiry/staffRole。
|
||
* 企微 userid 可手工维护,也可点「同步企业微信」由主账 upsert(姓名/手机/部门)。
|
||
* 浏览器禁止直连企微;岗位/负责人/线路仍手工改。
|
||
* 产品·航线:负责人(是/否)+ 负责线路多选,选项照搬旧项目 admin-staff。
|
||
*/
|
||
import { computed, onMounted, reactive, ref, watch } from "vue";
|
||
import { message, Modal } from "ant-design-vue";
|
||
import {
|
||
disableStaff,
|
||
getStaffCounts,
|
||
getStaffDepartments,
|
||
getStaffList,
|
||
getWecomDepartments,
|
||
saveStaff as saveStaffApi,
|
||
syncWecomStaff,
|
||
} from "/@/views/inquiry/staffRole/staffRole.api";
|
||
import ProtoIcon from "/@/views/inquiry/components/ProtoIcon.vue";
|
||
import AdminModal from "/@/views/inquiry/components/AdminModal.vue";
|
||
import AdminPagination from "/@/views/inquiry/components/AdminPagination.vue";
|
||
import { joinRoutes, parseRouteList } from "/@/views/inquiry/staffRole/staffRoutes";
|
||
import {
|
||
ROUTES_BY_ROLE,
|
||
dataPermTitle,
|
||
normalizeStaffRouteKeys,
|
||
} from "/@/views/inquiry/shared/dataPermCatalog";
|
||
|
||
type StaffRole = "sales" | "air" | "sea" | "land";
|
||
|
||
/** 部门下拉:value 存短名,label 带层级缩进 */
|
||
type DeptOption = { id: number; name: string; label: string; depth: number };
|
||
|
||
type StaffRow = {
|
||
id: string;
|
||
name: string;
|
||
phoneMasked: string;
|
||
dept: string;
|
||
wecomId: string;
|
||
role: StaffRole;
|
||
roleLabel: string;
|
||
routes: string;
|
||
/** 产品/航线是否负责人;销售为 null */
|
||
isLeader: boolean | null;
|
||
notifyEventException: boolean;
|
||
createdAt: string;
|
||
status: "active" | "disabled";
|
||
avatarColor: string;
|
||
};
|
||
|
||
const AVATAR_COLORS = ["#e11d48", "#f43f5e", "#7c3aed", "#0e7490", "#b54708", "#155eef", "#64748b"];
|
||
|
||
const roleLabelMap: Record<StaffRole, string> = {
|
||
sales: "销售",
|
||
air: "航线·空运",
|
||
sea: "产品·海运",
|
||
land: "产品·陆运",
|
||
};
|
||
|
||
/** 负责线路与角色数据权限同源(Excel《数据权限下拉选项》) */
|
||
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";
|
||
}
|
||
|
||
const rows = ref<StaffRow[]>([]);
|
||
const loading = ref(false);
|
||
const total = ref(0);
|
||
const pageNo = ref(1);
|
||
const pageSize = ref(10);
|
||
const counts = ref({ all: 0, sales: 0, air: 0, sea: 0, land: 0 });
|
||
const tab = ref<"all" | StaffRole>("all");
|
||
const filters = reactive({
|
||
keyword: "",
|
||
role: "" as "" | StaffRole,
|
||
route: "",
|
||
status: "" as "" | "active" | "disabled",
|
||
});
|
||
|
||
const modalOpen = ref(false);
|
||
const editing = ref<StaffRow | null>(null);
|
||
const syncing = ref(false);
|
||
/** 部门树扁平列表:一级不缩进,子级前加空格 */
|
||
const deptOptions = ref<DeptOption[]>([]);
|
||
const deptLoading = ref(false);
|
||
const deptLoaded = ref(false);
|
||
const deptOpen = ref(false);
|
||
const form = reactive({
|
||
name: "",
|
||
wecomId: "",
|
||
phone: "",
|
||
dept: "",
|
||
role: "sales" as StaffRole,
|
||
/** 空字符串=未选;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 { label: string; value: string }[];
|
||
return ROUTES_BY_ROLE[form.role].map((key) => ({
|
||
value: key,
|
||
label: dataPermTitle(key),
|
||
}));
|
||
});
|
||
|
||
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));
|
||
});
|
||
|
||
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;
|
||
return AVATAR_COLORS[h];
|
||
}
|
||
|
||
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 || "",
|
||
phoneMasked: r.mobile || "未填写",
|
||
dept: r.departName || "未分配",
|
||
wecomId: r.wecomId || "",
|
||
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",
|
||
avatarColor: colorFor(r.name || "员"),
|
||
};
|
||
}
|
||
|
||
async function loadCounts() {
|
||
try {
|
||
const res: any = await getStaffCounts();
|
||
const data = res?.result || res || {};
|
||
counts.value = {
|
||
all: Number(data.all || 0),
|
||
sales: Number(data.sales || 0),
|
||
air: Number(data.air || 0),
|
||
sea: Number(data.sea || 0),
|
||
land: Number(data.land || 0),
|
||
};
|
||
} catch {
|
||
/* 列表仍可用 */
|
||
}
|
||
}
|
||
|
||
async function loadList() {
|
||
loading.value = true;
|
||
try {
|
||
const roleCode = tab.value !== "all" ? tab.value : filters.role || undefined;
|
||
const res: any = await getStaffList({
|
||
keyword: filters.keyword.trim() || undefined,
|
||
roleCode,
|
||
routes: filters.route || undefined,
|
||
status: filters.status || undefined,
|
||
pageNo: pageNo.value,
|
||
pageSize: pageSize.value,
|
||
});
|
||
const page = res?.records ? res : res?.result || {};
|
||
const records = page.records || [];
|
||
total.value = Number(page.total || records.length || 0);
|
||
rows.value = (records as any[]).map(mapRow);
|
||
const maxPage = Math.max(1, Math.ceil(total.value / pageSize.value) || 1);
|
||
if (pageNo.value > maxPage) {
|
||
pageNo.value = maxPage;
|
||
await loadList();
|
||
return;
|
||
}
|
||
await loadCounts();
|
||
} catch (e: any) {
|
||
message.error(e?.message || "加载员工失败");
|
||
} finally {
|
||
loading.value = false;
|
||
}
|
||
}
|
||
|
||
let searchTimer: ReturnType<typeof setTimeout> | null = null;
|
||
watch(
|
||
() => [tab.value, filters.keyword, filters.role, filters.route, filters.status],
|
||
() => {
|
||
pageNo.value = 1;
|
||
if (searchTimer) clearTimeout(searchTimer);
|
||
searchTimer = setTimeout(() => loadList(), 200);
|
||
},
|
||
);
|
||
|
||
function onPageChange() {
|
||
loadList();
|
||
}
|
||
|
||
onMounted(() => loadList());
|
||
|
||
/** 企微根部门公司全称,下拉不展示 */
|
||
const ROOT_DEPT_NAME = "深圳市友通达供应链管理有限公司";
|
||
|
||
/**
|
||
* 按 parentId 排成树再前序展开。
|
||
* 展示:部门1 / 「 」子部门1 / 「 」子部门2 / 部门2(根公司不出现)。
|
||
* 保存仍用短名 name。
|
||
*/
|
||
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;
|
||
}
|
||
deptLoading.value = true;
|
||
try {
|
||
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);
|
||
}
|
||
deptLoaded.value = true;
|
||
} catch (e: any) {
|
||
message.warning(e?.message || "部门列表加载失败,可稍后重试");
|
||
} finally {
|
||
deptLoading.value = false;
|
||
ensureCurrentDeptOption();
|
||
}
|
||
}
|
||
|
||
function ensureCurrentDeptOption() {
|
||
const cur = (form.dept || "").trim();
|
||
if (!cur) return;
|
||
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;
|
||
deptFilterActive.value = false;
|
||
deptOpen.value = false;
|
||
}
|
||
|
||
function onDeptFocus() {
|
||
if (deptBlurTimer) {
|
||
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;
|
||
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 = normalizeStaffRouteKeys(form.routeList).filter((r) => valid.has(r));
|
||
},
|
||
);
|
||
|
||
function openCreate() {
|
||
editing.value = null;
|
||
Object.assign(form, {
|
||
name: "",
|
||
wecomId: "",
|
||
phone: "",
|
||
dept: "",
|
||
role: "sales",
|
||
isLeader: "",
|
||
routeList: [],
|
||
notifyEventException: false,
|
||
status: "active",
|
||
});
|
||
modalOpen.value = true;
|
||
deptLoaded.value = false;
|
||
ensureDeptOptions();
|
||
}
|
||
|
||
function openEdit(row: StaffRow) {
|
||
editing.value = row;
|
||
const valid = isProductOrAir(row.role) ? new Set(ROUTES_BY_ROLE[row.role]) : null;
|
||
// 旧 routes(全线/BKK/外采-空运 等)归一到 Excel 码后再过滤本岗选项
|
||
const routes = normalizeStaffRouteKeys(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,
|
||
isLeader: row.isLeader === true ? "true" : row.isLeader === false ? "false" : "",
|
||
routeList: routes,
|
||
notifyEventException: Boolean(row.notifyEventException),
|
||
status: row.status,
|
||
});
|
||
modalOpen.value = true;
|
||
deptLoaded.value = false;
|
||
ensureDeptOptions();
|
||
}
|
||
|
||
async function saveStaff() {
|
||
if (!form.name.trim() || !form.wecomId.trim()) {
|
||
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(),
|
||
wecomId: form.wecomId.trim(),
|
||
mobile: form.phone.trim() || undefined,
|
||
departName: form.dept.trim() || undefined,
|
||
roleCode: form.role,
|
||
roleLabel: roleLabelMap[form.role],
|
||
routes: isProductOrAir(form.role)
|
||
? joinRoutes(normalizeStaffRouteKeys(form.routeList))
|
||
: undefined,
|
||
isLeader: isProductOrAir(form.role) ? (form.isLeader === "true" ? 1 : 0) : null,
|
||
notifyEventException: form.notifyEventException ? 1 : 0,
|
||
status: form.status,
|
||
};
|
||
try {
|
||
await saveStaffApi(body, Boolean(editing.value));
|
||
message.success(editing.value ? "员工已更新" : "员工已创建");
|
||
modalOpen.value = false;
|
||
await loadList();
|
||
} catch (e: any) {
|
||
message.error(e?.message || "保存失败");
|
||
}
|
||
}
|
||
|
||
function removeStaff(row: StaffRow) {
|
||
Modal.confirm({
|
||
title: `停用员工 ${row.name}?`,
|
||
content: "主账无物理删除接口,确认后将状态设为禁用。",
|
||
okType: "danger",
|
||
async onOk() {
|
||
try {
|
||
await disableStaff(row.id);
|
||
message.success("已停用");
|
||
await loadList();
|
||
} catch (e: any) {
|
||
message.error(e?.message || "停用失败");
|
||
}
|
||
},
|
||
});
|
||
}
|
||
|
||
async function syncWecom() {
|
||
if (syncing.value) return;
|
||
syncing.value = true;
|
||
try {
|
||
const res: any = await syncWecomStaff();
|
||
const msg = res?.message || (typeof res === "string" ? res : "同步完成");
|
||
message.success(msg);
|
||
await loadList();
|
||
} catch (e: any) {
|
||
message.error(e?.message || "同步失败");
|
||
} finally {
|
||
syncing.value = false;
|
||
}
|
||
}
|
||
|
||
function routeTags(routes: string) {
|
||
return normalizeStaffRouteKeys(parseRouteList(routes)).map((k) => dataPermTitle(k));
|
||
}
|
||
|
||
function filterRouteOption(input: string, option: any) {
|
||
return String(option?.label ?? "").includes(input);
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<Teleport defer to="#page-topbar-actions">
|
||
<div class="staff-topbar-actions">
|
||
<button type="button" class="btn btn-secondary" :disabled="syncing" @click="syncWecom">
|
||
<ProtoIcon name="refresh" :size="16" />{{ syncing ? "同步中…" : "同步企业微信" }}
|
||
</button>
|
||
<button type="button" class="btn btn-primary" @click="openCreate">
|
||
<ProtoIcon name="userPlus" :size="16" />新增员工
|
||
</button>
|
||
</div>
|
||
</Teleport>
|
||
|
||
<div class="section-card is-list-page">
|
||
<div class="tabs-line-bar">
|
||
<div class="tabs-line">
|
||
<button type="button" :class="{ 'is-active': tab === 'all' }" @click="tab = 'all'">
|
||
全部 <span class="tab-count is-blue">{{ counts.all }}</span>
|
||
</button>
|
||
<button type="button" :class="{ 'is-active': tab === 'sales' }" @click="tab = 'sales'">
|
||
销售 <span class="tab-count is-rose">{{ counts.sales }}</span>
|
||
</button>
|
||
<button type="button" :class="{ 'is-active': tab === 'air' }" @click="tab = 'air'">
|
||
航线 · 空运 <span class="tab-count is-violet">{{ counts.air }}</span>
|
||
</button>
|
||
<button type="button" :class="{ 'is-active': tab === 'sea' }" @click="tab = 'sea'">
|
||
产品 · 海运 <span class="tab-count is-teal">{{ counts.sea }}</span>
|
||
</button>
|
||
<button type="button" :class="{ 'is-active': tab === 'land' }" @click="tab = 'land'">
|
||
产品 · 陆运 <span class="tab-count is-amber">{{ counts.land }}</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="toolbar">
|
||
<div class="search">
|
||
<ProtoIcon class="search-icon" name="search" :size="16" />
|
||
<input v-model="filters.keyword" placeholder="搜索姓名、企业微信ID、手机号..." />
|
||
</div>
|
||
<select v-model="filters.role">
|
||
<option value="">所属岗位</option>
|
||
<option value="sales">销售</option>
|
||
<option value="air">航线·空运</option>
|
||
<option value="sea">产品·海运</option>
|
||
<option value="land">产品·陆运</option>
|
||
</select>
|
||
<select v-model="filters.route">
|
||
<option value="">负责线路</option>
|
||
<option v-for="r in ALL_ROUTE_FILTER_OPTIONS" :key="r" :value="r">{{ dataPermTitle(r) }}</option>
|
||
</select>
|
||
<select v-model="filters.status">
|
||
<option value="">状态</option>
|
||
<option value="active">启用</option>
|
||
<option value="disabled">禁用</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div class="table-wrap">
|
||
<table class="data-table">
|
||
<thead>
|
||
<tr>
|
||
<th>员工</th>
|
||
<th>企业微信 ID</th>
|
||
<th>岗位</th>
|
||
<th>负责线路</th>
|
||
<th>创建时间</th>
|
||
<th>状态</th>
|
||
<th>操作</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr v-if="loading">
|
||
<td colspan="7" style="text-align: center; color: #94a3b8">加载中…</td>
|
||
</tr>
|
||
<tr v-else-if="!filtered.length">
|
||
<td colspan="7" style="text-align: center; color: #94a3b8">暂无员工</td>
|
||
</tr>
|
||
<tr v-for="row in filtered" :key="row.id">
|
||
<td>
|
||
<div class="avatar-chip">
|
||
<div class="av" :style="{ background: row.avatarColor }">{{ row.name.slice(-1) }}</div>
|
||
<div>
|
||
<div style="font-weight: 800">{{ row.name }}</div>
|
||
<div style="font-size: 10px; color: #64748b; font-weight: 700">
|
||
{{ row.dept }} · {{ row.phoneMasked }}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</td>
|
||
<td style="font-family: ui-monospace, monospace; font-size: 10px">{{ row.wecomId }}</td>
|
||
<td>{{ row.roleLabel }}</td>
|
||
<td>
|
||
<div v-if="routeTags(row.routes).length" class="role-perm-summary">
|
||
<span v-for="tag in routeTags(row.routes)" :key="tag" class="tag tag-violet">{{ tag }}</span>
|
||
</div>
|
||
<span v-else style="color: #94a3b8">-</span>
|
||
</td>
|
||
<td>{{ row.createdAt }}</td>
|
||
<td>
|
||
<span class="tag" :class="row.status === 'active' ? 'tag-green' : 'tag-rose'">
|
||
{{ row.status === "active" ? "启用" : "禁用" }}
|
||
</span>
|
||
</td>
|
||
<td>
|
||
<div style="display: flex; align-items: center; gap: 12px">
|
||
<button class="link-action" type="button" @click="openEdit(row)">编辑</button>
|
||
<button
|
||
v-if="row.status === 'active'"
|
||
class="link-action link-danger"
|
||
type="button"
|
||
@click="removeStaff(row)"
|
||
>
|
||
停用
|
||
</button>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
<AdminPagination
|
||
v-model:page-no="pageNo"
|
||
v-model:page-size="pageSize"
|
||
:total="total"
|
||
unit="名员工"
|
||
:disabled="loading"
|
||
@change="onPageChange"
|
||
/>
|
||
</div>
|
||
|
||
<AdminModal
|
||
v-model:open="modalOpen"
|
||
:title="editing ? '编辑员工' : '新增员工'"
|
||
size="md"
|
||
ok-text="确认"
|
||
@ok="saveStaff"
|
||
>
|
||
<div class="form-row">
|
||
<label class="lbl">姓名 <span class="req">*</span></label>
|
||
<input v-model="form.name" class="field" type="text" />
|
||
</div>
|
||
<div class="form-row">
|
||
<label class="lbl">手机号</label>
|
||
<input v-model="form.phone" class="field" type="tel" />
|
||
</div>
|
||
<div class="form-row">
|
||
<label class="lbl">企业微信 ID <span class="req">*</span></label>
|
||
<input v-model="form.wecomId" class="field field-mono" type="text" placeholder="如 zhangmin@yt" />
|
||
</div>
|
||
<div class="form-row">
|
||
<label class="lbl">所属部门</label>
|
||
<div class="dept-combo">
|
||
<input
|
||
v-model="form.dept"
|
||
class="field"
|
||
type="text"
|
||
autocomplete="off"
|
||
:disabled="deptLoading"
|
||
:placeholder="deptLoading ? '加载部门…' : '请选择或输入部门'"
|
||
@focus="onDeptFocus"
|
||
@input="onDeptInput"
|
||
@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>
|
||
<select v-model="form.role" class="field">
|
||
<option value="sales">销售</option>
|
||
<option value="air">航线·空运</option>
|
||
<option value="sea">产品·海运</option>
|
||
<option value="land">产品·陆运</option>
|
||
</select>
|
||
</div>
|
||
<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>
|
||
<label class="check-line">
|
||
<input v-model="form.notifyEventException" type="checkbox" />
|
||
<span>接收事件异常消息提醒</span>
|
||
</label>
|
||
</div>
|
||
<div class="form-row">
|
||
<label class="lbl">状态</label>
|
||
<div class="status-toggle">
|
||
<button type="button" :class="{ 'is-selected': form.status === 'active' }" @click="form.status = 'active'">
|
||
启用
|
||
</button>
|
||
<button
|
||
type="button"
|
||
:class="{ 'is-selected': form.status === 'disabled' }"
|
||
@click="form.status = 'disabled'"
|
||
>
|
||
禁用
|
||
</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"
|
||
:filter-option="filterRouteOption"
|
||
/>
|
||
</div>
|
||
</AdminModal>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.staff-topbar-actions {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
flex-wrap: nowrap;
|
||
}
|
||
|
||
.check-line {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
min-height: 34px;
|
||
font-size: 11px;
|
||
font-weight: 600;
|
||
color: #344054;
|
||
cursor: pointer;
|
||
user-select: none;
|
||
}
|
||
|
||
.check-line input {
|
||
width: 14px;
|
||
height: 14px;
|
||
margin: 0;
|
||
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;
|
||
}
|
||
|
||
.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>
|