落地报价模板主账与后台:合同映射、灌种 SQL/脚本、智能体填表,并补测服运维探针与合同文档对齐。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
linmianxun
2026-09-14 14:09:45 +08:00
co-authored by Cursor
parent 8bdf4990a2
commit a70bce3c8f
138 changed files with 7239 additions and 1085 deletions
@@ -1,20 +1,52 @@
<script setup lang="ts">
/**
* 员工管理:岗位 Tab、筛选、新建/编辑(Mock)。
* 企微 userid 绑定展示对齐原型;不向企微换 ID。
* 顶栏:同步企业微信 / 新建员工(Teleport)。
* 员工管理:对接主账 /inquiry/staffRole。
* 企微 userid 由运营手工绑定,不向企微换号;停用走 disable 接口。
*/
import { computed, reactive, ref } from "vue";
import { computed, onMounted, reactive, ref, watch } from "vue";
import { message, Modal } from "ant-design-vue";
import { mockStaff, type StaffRow } from "/@/views/inquiry/mock/staff";
import {
disableStaff,
getStaffCounts,
getStaffList,
saveStaff as saveStaffApi,
} from "/@/views/inquiry/staffRole/staffRole.api";
import ProtoIcon from "/@/views/inquiry/components/ProtoIcon.vue";
import AdminModal from "/@/views/inquiry/components/AdminModal.vue";
const rows = ref<StaffRow[]>([...mockStaff]);
const tab = ref<"all" | "sales" | "air" | "sea" | "land">("all");
type StaffRole = "sales" | "air" | "sea" | "land";
type StaffRow = {
id: string;
name: string;
phoneMasked: string;
dept: string;
wecomId: string;
role: StaffRole;
roleLabel: string;
routes: string;
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: "产品·陆运",
};
const rows = ref<StaffRow[]>([]);
const loading = ref(false);
const counts = ref({ all: 0, sales: 0, air: 0, sea: 0, land: 0 });
const tab = ref<"all" | StaffRole>("all");
const filters = reactive({
keyword: "",
role: "",
role: "" as "" | StaffRole,
route: "",
status: "" as "" | "active" | "disabled",
});
@@ -26,42 +58,86 @@ const form = reactive({
wecomId: "",
phone: "",
dept: "",
role: "sales" as StaffRow["role"],
role: "sales" as StaffRole,
routes: "",
/** 是否接收事件异常消息提醒;对齐旧仓 notifyEventException */
notifyEventException: false,
status: "active" as StaffRow["status"],
});
const counts = computed(() => ({
all: rows.value.length,
sales: rows.value.filter((r) => r.role === "sales").length,
air: rows.value.filter((r) => r.role === "air").length,
sea: rows.value.filter((r) => r.role === "sea").length,
land: rows.value.filter((r) => r.role === "land").length,
}));
const filtered = computed(() => rows.value);
const filtered = computed(() =>
rows.value.filter((r) => {
if (tab.value !== "all" && r.role !== tab.value) return false;
const kw = filters.keyword.trim().toLowerCase();
if (kw) {
const hay = `${r.name}${r.wecomId}${r.phoneMasked}`.toLowerCase();
if (!hay.includes(kw)) return false;
}
if (filters.role && r.role !== filters.role) return false;
if (filters.route && !r.routes.includes(filters.route)) return false;
if (filters.status && r.status !== filters.status) return false;
return true;
}),
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;
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 || "-",
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: 1,
pageSize: 200,
});
const records = res?.records || res?.result?.records || [];
rows.value = (records as any[]).map(mapRow);
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],
() => {
if (searchTimer) clearTimeout(searchTimer);
searchTimer = setTimeout(() => loadList(), 200);
},
);
const roleLabelMap: Record<StaffRow["role"], string> = {
sales: "销售",
air: "航线·空运",
sea: "产品·海运",
land: "产品·陆运",
};
onMounted(() => loadList());
function openCreate() {
editing.value = null;
@@ -83,8 +159,8 @@ function openEdit(row: StaffRow) {
Object.assign(form, {
name: row.name,
wecomId: row.wecomId,
phone: row.phoneMasked,
dept: row.dept,
phone: row.phoneMasked === "未填写" ? "" : row.phoneMasked,
dept: row.dept === "未分配" ? "" : row.dept,
role: row.role,
routes: row.routes === "-" ? "" : row.routes,
notifyEventException: Boolean(row.notifyEventException),
@@ -93,63 +169,60 @@ function openEdit(row: StaffRow) {
modalOpen.value = true;
}
function saveStaff() {
async function saveStaff() {
if (!form.name.trim() || !form.wecomId.trim()) {
message.warning("请填写姓名与企业微信 ID");
return;
}
if (editing.value) {
Object.assign(editing.value, {
name: form.name,
wecomId: form.wecomId,
phoneMasked: form.phone,
dept: form.dept,
role: form.role,
roleLabel: roleLabelMap[form.role],
routes: form.routes || "-",
notifyEventException: form.notifyEventException,
status: form.status,
});
message.success("员工已更新");
} else {
rows.value.unshift({
id: `s${Date.now()}`,
name: form.name,
phoneMasked: form.phone || "未填写",
dept: form.dept || "未分配",
wecomId: form.wecomId,
role: form.role,
roleLabel: roleLabelMap[form.role],
routes: form.routes || "-",
notifyEventException: form.notifyEventException,
createdAt: new Date().toISOString().slice(0, 10),
status: form.status,
avatarColor: "#155eef",
});
message.success("员工已创建");
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: form.routes.trim() || undefined,
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 || "保存失败");
}
modalOpen.value = false;
}
function removeStaff(row: StaffRow) {
Modal.confirm({
title: `删除员工 ${row.name}?`,
content: "删除后不可恢复,确认继续?",
title: `停用员工 ${row.name}?`,
content: "主账无物理删除接口,确认后将状态设为禁用。",
okType: "danger",
onOk() {
rows.value = rows.value.filter((r) => r.id !== row.id);
message.success("已删除");
async onOk() {
try {
await disableStaff(row.id);
message.success("已停用");
await loadList();
} catch (e: any) {
message.error(e?.message || "停用失败");
}
},
});
}
function syncWecom() {
message.success("已同步企业微信");
message.info("企微通讯录同步尚未开放,请手工维护企微 ID");
}
function routeTags(routes: string) {
if (!routes || routes === "-") return [];
return routes.split(/[/·,、]/).map((s) => s.trim()).filter(Boolean);
return routes
.split(/[/·,、]/)
.map((s) => s.trim())
.filter(Boolean);
}
</script>
@@ -231,6 +304,12 @@ function routeTags(routes: string) {
</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">
@@ -260,8 +339,13 @@ function routeTags(routes: string) {
<td>
<div style="display: flex; align-items: center; gap: 12px">
<button class="link-action" type="button" @click="openEdit(row)">编辑</button>
<button class="link-action link-danger" type="button" @click="removeStaff(row)">
删除
<button
v-if="row.status === 'active'"
class="link-action link-danger"
type="button"
@click="removeStaff(row)"
>
停用
</button>
</div>
</td>
@@ -323,11 +407,7 @@ function routeTags(routes: string) {
<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 type="button" :class="{ 'is-selected': form.status === 'active' }" @click="form.status = 'active'">
启用
</button>
<button