Files
inquiry_robot/inquiry-backend/src/views/inquiry/staffRole/index.vue
T

814 lines
24 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>