Compare commits

...
7 changed files with 425 additions and 155 deletions
@@ -114,7 +114,25 @@ public class InquiryStaffRoleController extends JeecgController<InquiryStaffRole
}
normalize(body);
body.setUpdateTime(new Date());
staffRoleService.updateById(body);
// 销售岗位需把 is_leader 写成 NULL;updateById 默认跳过 null,故用 UpdateWrapper
if ("sales".equals(body.getRoleCode())) {
staffRoleService.lambdaUpdate()
.eq(InquiryStaffRole::getId, body.getId())
.set(InquiryStaffRole::getName, body.getName())
.set(InquiryStaffRole::getWecomId, body.getWecomId())
.set(InquiryStaffRole::getMobile, body.getMobile())
.set(InquiryStaffRole::getDepartName, body.getDepartName())
.set(InquiryStaffRole::getRoleCode, body.getRoleCode())
.set(InquiryStaffRole::getRoleLabel, body.getRoleLabel())
.set(InquiryStaffRole::getRoutes, "")
.set(InquiryStaffRole::getIsLeader, null)
.set(InquiryStaffRole::getNotifyEventException, body.getNotifyEventException())
.set(InquiryStaffRole::getStatus, body.getStatus())
.set(InquiryStaffRole::getUpdateTime, body.getUpdateTime())
.update();
} else {
staffRoleService.updateById(body);
}
return Result.OK("更新成功");
}
@@ -214,5 +232,18 @@ public class InquiryStaffRoleController extends JeecgController<InquiryStaffRole
if (body.getNotifyEventException() == null) {
body.setNotifyEventException(0);
}
// 销售无负责人/线路;产品与航线须维护(与旧项目 admin-staff 一致)
String role = body.getRoleCode();
if ("sales".equals(role)) {
body.setIsLeader(null);
body.setRoutes("");
} else if ("air".equals(role) || "sea".equals(role) || "land".equals(role)) {
if (body.getIsLeader() == null) {
throw new IllegalArgumentException("请选择是否负责人");
}
if (StringUtils.isBlank(body.getRoutes())) {
throw new IllegalArgumentException("请选择负责线路");
}
}
}
}
@@ -50,9 +50,18 @@ public class InquiryStaffRole implements Serializable {
@ApiModelProperty("角色展示名")
private String roleLabel;
@ApiModelProperty("负责线路")
@ApiModelProperty("负责线路(逗号分隔;销售可空)")
private String routes;
/**
* 是否线路/产品负责人。
* <p>
* 仅航线·空运 / 产品·海运 / 产品·陆运有意义:1=是、0=否;销售岗位为 null。
* 成交/未成交等通知可按线路找负责人(后台维护,智能体后续读取)。
*/
@ApiModelProperty("是否负责人 1是0否,销售为空")
private Integer isLeader;
@ApiModelProperty("是否接收事件异常提醒 0/1")
private Integer notifyEventException;
@@ -1,6 +1,6 @@
/**
* 机器人回复语分类元数据(版式用,非演示业务数据)。
* 条目内容一律来自主账 /inquiry/botReply。
* 机器人回复语分类元数据(版式用)。
* 标题/说明与原型 admin-bot-replies.html 一致;条目来自主账。
*/
export type ReplyItem = {
@@ -53,25 +53,25 @@ export const REPLY_CATEGORY_META: Omit<ReplyCategory, "items">[] = [
{
id: "closed",
title: "已关闭",
desc: "工单因超时、重复、误建等原因关闭。",
desc: "工单长期无有效推进,由系统自动关闭或人工手动关闭,不等同于客户明确拒绝。",
tagClass: "tag-rose",
},
{
id: "event-ex",
title: "事件异常",
desc: "销售/产品/航线响应超时等业务事件异常提示。",
desc: "业务流程中参与方响应超时等业务侧异常,写入工单「事件异常」字段,需人工跟进。",
tagClass: "tag-amber",
},
{
id: "sys-ex",
title: "系统异常",
desc: "大模型、TMS、建群等系统失败话术。",
desc: "大模型、TMS、企微建群等系统侧调用失败,写入工单「系统异常」字段。",
tagClass: "tag-rose",
},
{
id: "transfer",
title: "转人工",
desc: "关键词/无价/解析失败等场景转人工话术。",
desc: "AI 无法独立完成询价时,引导销售确认是否拉内部航线/产品进群协同。",
tagClass: "tag-violet",
},
];
@@ -21,16 +21,13 @@ const COST_PLOT_TOP = 36;
const COST_PLOT_BOTTOM = 186;
const now = new Date();
const isoDay = now.toISOString().slice(0, 10);
const dateDisplay = ref(`${isoDay}——${isoDay}`);
const dateFrom = ref(isoDay);
/** 用本地日期,避免 toISOString 因时区偏到前一天 */
const pad2 = (n: number) => String(n).padStart(2, "0");
const isoDay = `${now.getFullYear()}-${pad2(now.getMonth() + 1)}-${pad2(now.getDate())}`;
/** 默认:当月 1 号 → 今天(不是当天到当天) */
const isoMonthStart = `${now.getFullYear()}-${pad2(now.getMonth() + 1)}-01`;
const dateFrom = ref(isoMonthStart);
const dateTo = ref(isoDay);
const dateFromMonth = ref(isoDay.slice(0, 7));
const dateToMonth = ref(isoDay.slice(0, 7));
const dateFromYear = ref(isoDay.slice(0, 4));
const dateToYear = ref(isoDay.slice(0, 4));
const pickerOpen = ref(false);
const pickerMode = ref<"day" | "month" | "year">("day");
const updatedTime = ref("00:00");
const routeFilterOpen = ref(false);
const selectedRoutes = ref<string[]>([]);
@@ -60,18 +57,34 @@ const INQUIRY_PLOT_TOP = 36;
const INQUIRY_PLOT_BOTTOM = 188;
function resolveRange(): string {
if (pickerMode.value === "day") {
const from = new Date(dateFrom.value);
const to = new Date(dateTo.value);
const days = Math.max(0, Math.round((to.getTime() - from.getTime()) / 86400000));
if (days <= 0) return "today";
if (days <= 6) return "7d";
return "30d";
}
if (pickerMode.value === "month") return "30d";
const from = new Date(dateFrom.value);
const to = new Date(dateTo.value);
const days = Math.max(0, Math.round((to.getTime() - from.getTime()) / 86400000));
if (days <= 0) return "today";
if (days <= 6) return "7d";
return "30d";
}
/**
* 顶栏日期:与工单列表同一套 a-range-picker(双月历点选起止)。
* 不再并排放「按日/月/年」切换,避免顶栏挤换行显得乱。
*/
const dateRange = computed({
get(): [string, string] | undefined {
if (!dateFrom.value && !dateTo.value) return undefined;
return [dateFrom.value || "", dateTo.value || ""];
},
set(v: [string, string] | null | undefined) {
dateFrom.value = v?.[0] ? String(v[0]) : isoMonthStart;
dateTo.value = v?.[1] ? String(v[1]) : isoDay;
},
});
/** 选完起止日后立刻刷新看板 */
function onDateRangeChange() {
loadMetrics();
}
async function loadMetrics() {
loading.value = true;
try {
@@ -240,14 +253,6 @@ const costXs = computed(() => {
return costDates.value.map((_, i) => (n === 1 ? 380 : 36 + (654 / (n - 1)) * i));
});
function applyDateRange() {
if (pickerMode.value === "day") dateDisplay.value = `${dateFrom.value}——${dateTo.value}`;
else if (pickerMode.value === "month") dateDisplay.value = `${dateFromMonth.value}——${dateToMonth.value}`;
else dateDisplay.value = `${dateFromYear.value}——${dateToYear.value}`;
pickerOpen.value = false;
loadMetrics();
}
function refreshData() {
loadMetrics().then(() => message.success("数据已更新"));
}
@@ -334,7 +339,6 @@ function exportRanking() {
function onDocClick(e: MouseEvent) {
const t = e.target as HTMLElement;
if (!t.closest(".month-range-picker")) pickerOpen.value = false;
if (!t.closest(".ref-route-filter")) routeFilterOpen.value = false;
}
@@ -353,33 +357,21 @@ onUnmounted(() => document.removeEventListener("click", onDocClick));
<!-- defer:等布局挂好 #page-topbar-actions 再传送,避免顶栏工具丢失 -->
<Teleport defer to="#page-topbar-actions">
<div class="dash-topbar-tools">
<div class="month-range-picker" @click.stop>
<div class="month-range-input" @click="pickerOpen = !pickerOpen">
<span>{{ dateDisplay }}</span>
<ProtoIcon name="calendar" :size="14" style="color: #94a3b8" />
</div>
<div v-show="pickerOpen" class="month-range-dropdown dash-picker-dropdown">
<div class="month-range-tabs">
<button type="button" class="month-range-tab" :class="{ 'is-active': pickerMode === 'day' }" @click="pickerMode = 'day'">按日</button>
<button type="button" class="month-range-tab" :class="{ 'is-active': pickerMode === 'month' }" @click="pickerMode = 'month'">按月</button>
<button type="button" class="month-range-tab" :class="{ 'is-active': pickerMode === 'year' }" @click="pickerMode = 'year'">按年</button>
</div>
<div class="dash-picker-body">
<template v-if="pickerMode === 'day'">
<input v-model="dateFrom" type="date" class="dash-picker-input" />
<input v-model="dateTo" type="date" class="dash-picker-input" />
</template>
<template v-else-if="pickerMode === 'month'">
<input v-model="dateFromMonth" type="month" class="dash-picker-input" />
<input v-model="dateToMonth" type="month" class="dash-picker-input" />
</template>
<template v-else>
<input v-model="dateFromYear" type="number" min="2020" max="2035" class="dash-picker-input" placeholder="起始年" />
<input v-model="dateToYear" type="number" min="2020" max="2035" class="dash-picker-input" placeholder="结束年" />
</template>
<button type="button" class="btn btn-primary" style="width: 100%; min-height: 32px; height: 32px" @click="applyDateRange">确定</button>
</div>
</div>
<div class="toolbar-date-range dash-toolbar-date" title="选择统计起止日期">
<a-range-picker
v-model:value="dateRange"
class="toolbar-range-picker dash-range-picker"
value-format="YYYY-MM-DD"
separator="至"
:placeholder="['开始日期', '结束日期']"
:allow-clear="false"
:input-read-only="true"
@change="onDateRangeChange"
>
<template #suffixIcon>
<ProtoIcon name="calendar" :size="14" />
</template>
</a-range-picker>
</div>
<div class="dashboard-data-note" aria-live="polite">
<span class="dashboard-data-note__icon"><ProtoIcon name="clock" :size="13" /></span>
@@ -634,38 +626,30 @@ onUnmounted(() => document.removeEventListener("click", onDocClick));
<style scoped>
.dash-topbar-tools {
display: flex;
flex-wrap: wrap;
flex-wrap: nowrap;
align-items: center;
gap: 8px;
}
.dash-picker-dropdown {
position: absolute;
right: 0;
top: calc(100% + 8px);
width: 300px;
z-index: 80;
.dash-toolbar-date {
height: 34px;
flex-shrink: 0;
}
.dash-picker-body {
display: grid;
gap: 8px;
padding-top: 4px;
}
.dash-picker-input {
width: 100%;
height: 32px;
padding: 0 10px;
border: 1px solid #dfe5ee;
border-radius: 7px;
font-size: 11px;
font-weight: 700;
.dash-toolbar-date :deep(.ant-picker) {
height: 34px !important;
min-height: 34px !important;
width: 260px !important;
}
.dash-refresh-btn {
height: 34px;
min-height: 34px;
flex-shrink: 0;
}
.dashboard-data-note {
flex-shrink: 0;
}
.dash-chart-title {
@@ -1,5 +1,6 @@
/**
* 机器人回复语:九分类对齐原型 admin-bot-replies.html(条目精简但仍覆盖各类)。
* 机器人回复语:完整照搬原型 admin-bot-replies.html(V1.0.24)。
* 共 9 类 37 条;与 deploy/sql/inquiry_bot_reply_proto_seed_v1.sql 同源。
*/
export type ReplyItem = {
@@ -44,8 +45,26 @@ export const mockReplyCategories: ReplyCategory[] = [
},
{
id: "r4",
trigger: "AI 解析失败 → 要求补充参数",
text: "未能完全理解需求,请补充或确认:起运地?目的地?件数?毛重?体积?如有品名和时效要求,也请一并补充。",
enabled: true,
},
{
id: "r5",
trigger: "TMS 命中标准价 → 询问是否内部协同",
text: "TMS 标准报价合计 {{system_quote}},预计时效 {{eta}}。是否需要咨询内部人员,或直接生成报价单?",
text: "TMS 标准报价合计 {{system_quote}},预计时效 {{eta}}。价格明细:空运价、操作费、文件费、报关费、提货费、目的地杂费、其他附加费。{{history_hint}}是否需要咨询内部{{internal_role_label}}人员,或直接生成报价单?",
enabled: true,
},
{
id: "r6",
trigger: "销售确认创建内部协同群",
text: "正在创建内部协同群「{{group_name}}」,并拉入销售 {{sales_name}}、{{internal_staff}} 及询价机器人。工单 {{ticket_no}} 保持「询价中」,等待内部反馈。",
enabled: true,
},
{
id: "r7",
trigger: "内部人员回复手工报价",
text: "收到 {{staff_name}}({{staff_role}})的手工报价,已结构化处理。报价 {{manual_quote}},附件 {{quote_attachment}} 已存入工单,正在整理报价方案…",
enabled: true,
},
],
@@ -57,21 +76,39 @@ export const mockReplyCategories: ReplyCategory[] = [
tagClass: "tag-blue",
items: [
{
id: "r5",
id: "r8",
trigger: "报价方案整理完成 → 询问是否采用",
text: "工单 {{ticket_no}} 状态已更新为「已报价」。整理后的报价方案:{{route_summary}} · {{quote_amount}} · {{eta}}。请确认是否采用。",
text: "工单 {{ticket_no}} 状态已更新为「已报价」。整理后的报价方案:{{route_summary}} · {{quote_amount}} · {{eta}}。请确认是否采用:(采用,生成 Excel 报价单 / 采用,生成 PDF 报价单 / 不采用)",
enabled: true,
},
{
id: "r6",
id: "r9",
trigger: "销售确认采用 → 生成最终报价单",
text: "已确认采用该方案,正在生成 {{quote_format}} 最终报价单…工单保持「已报价」,完成后可直接转发给客户。",
enabled: true,
},
{
id: "r10",
trigger: "最终报价单生成完成",
text: "最终报价单已生成:{{quote_file}}({{quote_amount}} · {{route_summary}})。工单 {{ticket_no}} 保持「已报价」,报价单已归档。",
enabled: true,
},
{
id: "r7",
id: "r11",
trigger: "销售发送比例调价指令",
text: "已识别调价指令:在当前报价基础上{{adjust_direction}}{{adjust_percent}}%。原报价 {{original_quote}} → 新报价 {{new_quote}}。正在刷新报价并重新生成报价单,工单保持「已报价」。",
enabled: true,
},
{
id: "r12",
trigger: "二次询价 · 关联历史工单",
text: "识别为二次询价,已关联历史工单 {{history_ticket_no}}。TMS 标准报价 {{system_quote}};上次报价 {{history_quote}},历史报价文档已引用至当前会话。",
enabled: true,
},
{
id: "r13",
trigger: "报价单出具满*小时 → 发起成交跟进",
text: "工单 {{ticket_no}} 已于 {{quote_time}} 出具报价。请确认当前与客户的成交状态:(成交 / 未成交 / 协商中)",
text: "工单 {{ticket_no}} 已于 {{quote_time}} 出具报价({{route_summary}} · {{quote_amount}})。请确认当前与客户的成交状态:(成交 / 未成交 / 协商中)",
enabled: true,
},
],
@@ -83,15 +120,15 @@ export const mockReplyCategories: ReplyCategory[] = [
tagClass: "tag-green",
items: [
{
id: "r8",
id: "r14",
trigger: "销售在成交跟进中选择「成交」",
text: "已记录为「已成交」。工单 {{ticket_no}} 状态已更新,成交确认时间 {{deal_time}} 已写入后台。",
text: "已记录为「已成交」。工单 {{ticket_no}} 状态已更新,成交确认时间 {{deal_time}} 已写入后台。已推送成交通知至 {{manager_name}}({{manager_role}})。",
enabled: true,
},
{
id: "r9",
id: "r15",
trigger: "成交确认 → 企微推送负责人",
text: "【成交通知】工单 {{ticket_no}} 已成交。销售:{{sales_name}} · 路线:{{route_summary}} · 报价:{{quote_amount}}。",
text: "【成交通知】工单 {{ticket_no}} 已成交。销售:{{sales_name}} · 业务:{{biz_type}} · 路线:{{route_summary}}报价:{{quote_amount}} · 确认时间:{{deal_time}}推送对象:{{manager_name}}({{manager_role}})。请知悉并安排后续履约跟进。",
enabled: true,
},
],
@@ -103,15 +140,27 @@ export const mockReplyCategories: ReplyCategory[] = [
tagClass: "tag-amber",
items: [
{
id: "r10",
id: "r16",
trigger: "销售选择「不采用」当前报价方案",
text: "工单 {{ticket_no}} 状态已更新为「协商中」。请问希望如何调整?",
text: "工单 {{ticket_no}} 状态已更新为「协商中」。请问希望如何调整?可说明调价幅度、换舱/时效要求,或是否需要重新拉内部{{internal_role_label}}协同。",
enabled: true,
},
{
id: "r11",
id: "r17",
trigger: "成交跟进中选择「协商中」",
text: "已记录为「协商中」。工单跟进周期已顺延,将于 {{next_followup_time}} 再次询问成交状态。",
text: "已记录为「协商中」。工单跟进周期已顺延 {{negotiate_extend_days}}个工作日,将于 {{next_followup_time}} 再次向您发起成交状态询问。",
enabled: true,
},
{
id: "r18",
trigger: "协商跟进周期到期 → 再次询问",
text: "工单 {{ticket_no}} 协商跟进周期已到({{route_summary}} · {{quote_amount}})。请再次确认当前成交状态:(成交 / 未成交 / 协商中)",
enabled: true,
},
{
id: "r19",
trigger: "调价或重新协同完成 → 出具新方案",
text: "协商调整已完成,新报价方案:{{route_summary}} · {{quote_amount}} · {{eta}}。工单状态更新为「已报价」,请确认是否采用。",
enabled: true,
},
],
@@ -123,15 +172,21 @@ export const mockReplyCategories: ReplyCategory[] = [
tagClass: "tag-rose",
items: [
{
id: "r12",
trigger: "销售选择「未成交」",
text: "已记录为「未成交」。请补充未成交原因,工单 {{ticket_no}} 将关闭智能体跟进。",
id: "r20",
trigger: "成交跟进中选择「未成交」",
text: "请告知{{ticket_no}}「未成交」的原因(必填)。回复后系统将记录至工单 {{ticket_no}},并推送未成交通知至对应负责人。",
enabled: true,
},
{
id: "r13",
trigger: "未成交 → 通知负责人",
text: "【未成交通知】工单 {{ticket_no}} 未成交。原因:{{lost_reason}}。",
id: "r21",
trigger: "销售回复未成交原因 → 关单并通知负责人",
text: "已记录为「未成交」。原因:{{reject_reason}}。登记时间 {{reject_time}},已推送未成交通知至 {{manager_name}}({{manager_role}})。工单 {{ticket_no}} 已归档。",
enabled: true,
},
{
id: "r22",
trigger: "未成交确认 → 企微推送负责人",
text: "【未成交通知】工单 {{ticket_no}} 未成交。销售:{{sales_name}} · 业务:{{biz_type}} · 路线:{{route_summary}}未成交原因:{{reject_reason}}登记时间:{{reject_time}} · 推送对象:{{manager_name}}({{manager_role}})。请知悉本单未成单情况。",
enabled: true,
},
],
@@ -139,13 +194,19 @@ export const mockReplyCategories: ReplyCategory[] = [
{
id: "closed",
title: "已关闭",
desc: "工单因超时、重复、误建等原因关闭。",
desc: "工单长期无有效推进,由系统自动关闭或人工手动关闭,不等同于客户明确拒绝。",
tagClass: "tag-rose",
items: [
{
id: "r14",
trigger: "销售主动关闭工单",
text: "工单 {{ticket_no}} 已关闭。如需再次询价,请重新描述需求。",
id: "r23",
trigger: "达到配置超时时间 → 系统自动关闭",
text: "工单 {{ticket_no}} 因「{{close_reason}}」已达关闭条件,系统已自动关闭。关闭时间:{{close_time}}。本单不再发起成交跟进,亦不触发成交/未成交通知。",
enabled: true,
},
{
id: "r24",
trigger: "销售/客服手动关闭工单",
text: "工单 {{ticket_no}} 已由 {{operator_name}} 手动关闭。关闭原因:{{close_reason}}。如需重新询价,请发起新的询价对话。",
enabled: true,
},
],
@@ -153,19 +214,25 @@ export const mockReplyCategories: ReplyCategory[] = [
{
id: "event-ex",
title: "事件异常",
desc: "销售/产品/航线响应超时等业务事件异常提示。",
desc: "业务流程中参与方响应超时等业务侧异常,写入工单「事件异常」字段,需人工跟进。",
tagClass: "tag-amber",
items: [
{
id: "r15",
trigger: "销售响应超时",
text: "工单 {{ticket_no}} 销售响应超时,已通知负责人跟进。",
id: "r25",
trigger: "内部协同*小时无响应 → 群内提醒",
text: "@{{staff_name}} @{{sales_name}} 工单 {{ticket_no}} 已超过 {{timeout_hours}} 小时未收到内部协同回复,请尽快确认。事件异常:{{event_exception_type}}。",
enabled: true,
},
{
id: "r16",
trigger: "产品 / 航线响应超时",
text: "内部协同人员响应超时,工单 {{ticket_no}} 已标记事件异常。",
id: "r26",
trigger: "内部协同超时达告警阈值 → 生成告警工单",
text: "工单 {{ticket_no}} 内部协同已超过 {{alert_hours}} 小时未响应,已生成「{{event_exception_type}}」告警,请管理员跟进。未响应人员:{{staff_name}}({{staff_role}})。",
enabled: true,
},
{
id: "r27",
trigger: "成交跟进多次无响应 → 标记销售响应超时",
text: "工单 {{ticket_no}} 成交跟进已发起 {{followup_count}} 次,销售仍未回复。事件异常:销售响应超时。如继续无响应,系统将按配置自动关闭工单。",
enabled: true,
},
],
@@ -173,19 +240,31 @@ export const mockReplyCategories: ReplyCategory[] = [
{
id: "sys-ex",
title: "系统异常",
desc: "大模型、TMS、建群等系统失败话术。",
desc: "大模型、TMS、企微建群等系统侧调用失败,写入工单「系统异常」字段。",
tagClass: "tag-rose",
items: [
{
id: "r17",
trigger: "大模型异常",
text: "需求识别暂时失败,请稍后重试或补充文字描述。工单 {{ticket_no}} 已记录系统异常。",
id: "r28",
trigger: "大模型调用失败",
text: "AI 解析暂时不可用({{system_error_detail}})。工单 {{ticket_no}} 已标记系统异常:大模型异常。请稍后重试,或回复「人工报价」转人工处理。",
enabled: true,
},
{
id: "r18",
trigger: "TMS 异常",
text: "TMS 标准价查询失败,已记录系统异常。可选择转内部人工报价。",
id: "r29",
trigger: "TMS 接口调用失败",
text: "TMS 标准价库查询失败({{system_error_detail}})。工单已标记系统异常:TMS异常。可稍后重试,或选择转人工由内部人员报价。",
enabled: true,
},
{
id: "r30",
trigger: "企微建群 / 添加成员失败",
text: "创建协同群失败,无法添加 {{staff_name}}({{staff_wechat_id}})。系统异常:建群异常 · {{system_error_detail}}。已在后台生成异常工单,请联系管理员检查人员配置。",
enabled: true,
},
{
id: "r31",
trigger: "系统异常处理完成",
text: "工单 {{ticket_no}} 系统异常「{{system_exception_type}}」已处理完毕,工单状态恢复为「{{restored_status}}」。处理记录:{{handle_note}}",
enabled: true,
},
],
@@ -193,19 +272,43 @@ export const mockReplyCategories: ReplyCategory[] = [
{
id: "transfer",
title: "转人工",
desc: "关键词/无价/解析失败等场景转人工话术。",
desc: "AI 无法独立完成询价时,引导销售确认是否拉内部航线/产品进群协同。",
tagClass: "tag-violet",
items: [
{
id: "r19",
trigger: "销售要求转人工",
text: "已为您转接人工处理,请稍候。工单 {{ticket_no}} 保持当前状态。",
id: "r32",
trigger: "TMS 暂无报价",
text: "暂无 TMS 标准报价。触发转人工:{{transfer_reason}}。是否拉{{internal_role_label}}进群协同?(是 / 否)",
enabled: true,
},
{
id: "r20",
trigger: "无 TMS 标准价 → 引导转人工",
text: "当前线路暂无标准价,建议转内部人工报价或转人工客服。",
id: "r33",
trigger: "意图识别失败",
text: "已尝试 {{intent_retry}} 次澄清,仍无法识别询价意图。触发转人工:意图识别失败。是否拉{{internal_role_label}}进群协同?(是 / 否)",
enabled: true,
},
{
id: "r34",
trigger: "参数提取不全",
text: "已连续 {{param_retry}} 次反问补充询价参数仍未完整。触发转人工:参数提取不全。是否拉{{internal_role_label}}进群协同?(是 / 否)",
enabled: true,
},
{
id: "r35",
trigger: "报价解析失败",
text: "触发转人工:{{transfer_reason}}(已连续失败 {{fail_count}} 次)。是否拉{{internal_role_label}}进群协同?(是 / 否)",
enabled: true,
},
{
id: "r36",
trigger: "空运锁舱/释放舱位失败",
text: "触发转人工:{{transfer_reason}}(已连续失败 {{fail_count}} 次)。请航线人员在 TMS 侧手工处理,或手动拉相关人员协助。请务必在群聊发送工单编号 {{ticket_no}}。",
enabled: true,
},
{
id: "r37",
trigger: "销售发送「人工报价」",
text: "已识别转人工指令。{{confirm_pull_group}}正在创建内部协同群,拉入销售 {{sales_name}}、{{internal_staff}} 及询价机器人…工单 {{ticket_no}} 进入「询价中」。",
enabled: true,
},
],
@@ -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>
@@ -191,14 +191,28 @@ body {
.page-topbar-actions-slot {
gap: 8px !important;
min-height: 34px !important;
max-height: 40px !important;
/* 勿 max-height + overflow:hidden:会裁掉看板日期下拉,看起来像不能选 */
max-height: none !important;
position: relative !important;
z-index: 40 !important;
pointer-events: auto !important;
overflow: hidden !important;
overflow: visible !important;
flex-wrap: nowrap !important;
}
.page-topbar-actions-slot .dash-topbar-tools,
.page-topbar-actions-slot .month-range-picker,
.page-topbar-actions-slot .toolbar-date-range {
position: relative !important;
overflow: visible !important;
z-index: 50 !important;
}
.page-topbar-actions-slot .month-range-dropdown,
.page-topbar-actions-slot .dash-picker-dropdown {
z-index: 90 !important;
}
.page-topbar-actions-slot .btn {
pointer-events: auto !important;
cursor: pointer !important;