合并远程主账与后台更新:保留查价出单,并纳入企微通讯录同步与工单展示。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+47
-1
@@ -11,21 +11,28 @@ import org.apache.shiro.authz.annotation.RequiresPermissions;
|
||||
import org.jeecg.common.api.vo.Result;
|
||||
import org.jeecg.common.aspect.annotation.AutoLog;
|
||||
import org.jeecg.common.system.base.controller.JeecgController;
|
||||
import org.apache.shiro.SecurityUtils;
|
||||
import org.jeecg.common.system.vo.LoginUser;
|
||||
import org.jeecg.modules.inquiry.dto.StaffWecomSyncResult;
|
||||
import org.jeecg.modules.inquiry.entity.InquiryStaffRole;
|
||||
import org.jeecg.modules.inquiry.service.IInquiryStaffRoleService;
|
||||
import org.jeecg.modules.inquiry.wecom.WeComAddressBookClient;
|
||||
import org.jeecg.modules.inquiry.wecom.WeComDepartment;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import java.util.Date;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 员工岗位管理 API。
|
||||
* <p>
|
||||
* 路径前缀 /inquiry/staffRole;权限码 inquiry:staff:*。
|
||||
* 不做企微换号,wecom_id 由运营手工绑定。
|
||||
* wecom_id 可手工维护,也可经 /syncWecom 从企微通讯录 upsert(姓名/手机/部门)。
|
||||
* 浏览器禁止直连企微;同步只服务展示,不挡询价主路径。
|
||||
*/
|
||||
@Slf4j
|
||||
@Api(tags = "询价-员工岗位")
|
||||
@@ -36,6 +43,9 @@ public class InquiryStaffRoleController extends JeecgController<InquiryStaffRole
|
||||
@Autowired
|
||||
private IInquiryStaffRoleService staffRoleService;
|
||||
|
||||
@Autowired
|
||||
private WeComAddressBookClient weComAddressBookClient;
|
||||
|
||||
private static final Map<String, String> ROLE_LABEL = new HashMap<>();
|
||||
|
||||
static {
|
||||
@@ -122,6 +132,42 @@ public class InquiryStaffRoleController extends JeecgController<InquiryStaffRole
|
||||
return Result.OK(staffRoleService.getById(id));
|
||||
}
|
||||
|
||||
/**
|
||||
* 企微通讯录全量 upsert。
|
||||
* 权限:编辑人员即可同步;不自动删/停用企微侧消失的人。
|
||||
*/
|
||||
@AutoLog(value = "员工岗位-同步企微通讯录")
|
||||
@ApiOperation("同步企微通讯录")
|
||||
@RequiresPermissions("inquiry:staff:edit")
|
||||
@PostMapping("/syncWecom")
|
||||
public Result<StaffWecomSyncResult> syncWecom() {
|
||||
String operator = currentUsername();
|
||||
StaffWecomSyncResult result = staffRoleService.syncFromWeCom(operator);
|
||||
return Result.OK(result.getMessage(), result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 企微部门下拉选项。浏览器禁止直连企微,只打本接口。
|
||||
*/
|
||||
@ApiOperation("企微部门列表")
|
||||
@RequiresPermissions("inquiry:staff:list")
|
||||
@GetMapping("/wecomDepartments")
|
||||
public Result<List<WeComDepartment>> wecomDepartments() {
|
||||
return Result.OK(weComAddressBookClient.listDepartments());
|
||||
}
|
||||
|
||||
private String currentUsername() {
|
||||
try {
|
||||
Object principal = SecurityUtils.getSubject().getPrincipal();
|
||||
if (principal instanceof LoginUser) {
|
||||
return ((LoginUser) principal).getUsername();
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
// 无登录主体时用系统标记
|
||||
}
|
||||
return "system";
|
||||
}
|
||||
|
||||
private void normalize(InquiryStaffRole body) {
|
||||
if (StringUtils.isBlank(body.getName()) || StringUtils.isBlank(body.getWecomId())) {
|
||||
throw new IllegalArgumentException("姓名与企微 userid 不能为空");
|
||||
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
package org.jeecg.modules.inquiry.dto;
|
||||
|
||||
import io.swagger.annotations.ApiModel;
|
||||
import io.swagger.annotations.ApiModelProperty;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 企微通讯录同步结果(给后台 toast / 日志)。
|
||||
*/
|
||||
@Data
|
||||
@ApiModel("员工企微同步结果")
|
||||
public class StaffWecomSyncResult {
|
||||
|
||||
@ApiModelProperty("企微侧已激活成员数")
|
||||
private int fetched;
|
||||
|
||||
@ApiModelProperty("新建条数")
|
||||
private int created;
|
||||
|
||||
@ApiModelProperty("更新条数")
|
||||
private int updated;
|
||||
|
||||
@ApiModelProperty("跳过条数(无 userid 等)")
|
||||
private int skipped;
|
||||
|
||||
@ApiModelProperty("摘要文案")
|
||||
private String message;
|
||||
}
|
||||
+11
@@ -1,6 +1,7 @@
|
||||
package org.jeecg.modules.inquiry.service;
|
||||
|
||||
import com.baomidou.mybatisplus.extension.service.IService;
|
||||
import org.jeecg.modules.inquiry.dto.StaffWecomSyncResult;
|
||||
import org.jeecg.modules.inquiry.entity.InquiryStaffRole;
|
||||
|
||||
import java.util.Map;
|
||||
@@ -19,4 +20,14 @@ public interface IInquiryStaffRoleService extends IService<InquiryStaffRole> {
|
||||
* 停用员工(status=disabled),不物理删除。
|
||||
*/
|
||||
void disable(String id);
|
||||
|
||||
/**
|
||||
* 从企微通讯录全量 upsert:按 wecom_id 更新姓名/手机/部门,缺失则新建(默认销售岗)。
|
||||
* <p>
|
||||
* 不删、不停用企微侧消失的人;不覆盖已有 roleCode / routes / 提醒开关。
|
||||
*
|
||||
* @param operator 操作人(写入 createBy/updateBy)
|
||||
* @return 同步计数
|
||||
*/
|
||||
StaffWecomSyncResult syncFromWeCom(String operator);
|
||||
}
|
||||
|
||||
+80
-2
@@ -2,9 +2,15 @@ package org.jeecg.modules.inquiry.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
|
||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.jeecg.modules.inquiry.dto.StaffWecomSyncResult;
|
||||
import org.jeecg.modules.inquiry.entity.InquiryStaffRole;
|
||||
import org.jeecg.modules.inquiry.mapper.InquiryStaffRoleMapper;
|
||||
import org.jeecg.modules.inquiry.service.IInquiryStaffRoleService;
|
||||
import org.jeecg.modules.inquiry.wecom.WeComAddressBookClient;
|
||||
import org.jeecg.modules.inquiry.wecom.WeComMember;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@@ -14,15 +20,25 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 员工岗位 CRUD 实现。
|
||||
* 员工岗位 CRUD + 企微通讯录 upsert。
|
||||
* <p>
|
||||
* 线程:跟随 HTTP 请求线程;无全局锁。wecom_id 唯一约束由库保证。
|
||||
* 线程:跟随 HTTP 请求线程;同步方法串行化,避免并发双写同一 wecom_id。
|
||||
* 无全局工单大锁;wecom_id 唯一约束由库保证。
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
public class InquiryStaffRoleServiceImpl extends ServiceImpl<InquiryStaffRoleMapper, InquiryStaffRole>
|
||||
implements IInquiryStaffRoleService {
|
||||
|
||||
private static final String[] ROLE_CODES = {"sales", "air", "sea", "land"};
|
||||
private static final String DEFAULT_ROLE = "sales";
|
||||
private static final String DEFAULT_ROLE_LABEL = "销售";
|
||||
|
||||
@Autowired
|
||||
private WeComAddressBookClient weComAddressBookClient;
|
||||
|
||||
/** 同步互斥:同一进程内禁止两路并发全量 upsert。 */
|
||||
private final Object syncLock = new Object();
|
||||
|
||||
@Override
|
||||
public Map<String, Long> countByRole() {
|
||||
@@ -54,4 +70,66 @@ public class InquiryStaffRoleServiceImpl extends ServiceImpl<InquiryStaffRoleMap
|
||||
public InquiryStaffRole findByWecomId(String wecomId) {
|
||||
return getOne(new QueryWrapper<InquiryStaffRole>().eq("wecom_id", wecomId).last("LIMIT 1"), false);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public StaffWecomSyncResult syncFromWeCom(String operator) {
|
||||
synchronized (syncLock) {
|
||||
List<WeComMember> members = weComAddressBookClient.listActiveMembers();
|
||||
int created = 0;
|
||||
int updated = 0;
|
||||
int skipped = 0;
|
||||
Date now = new Date();
|
||||
String op = StringUtils.defaultIfBlank(operator, "wecom-sync");
|
||||
|
||||
for (WeComMember m : members) {
|
||||
if (m == null || StringUtils.isBlank(m.getUserid())) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
InquiryStaffRole existing = findByWecomId(m.getUserid());
|
||||
if (existing == null) {
|
||||
InquiryStaffRole row = new InquiryStaffRole();
|
||||
row.setWecomId(m.getUserid());
|
||||
row.setName(StringUtils.defaultIfBlank(m.getName(), m.getUserid()));
|
||||
row.setMobile(StringUtils.trimToNull(m.getMobile()));
|
||||
row.setDepartName(StringUtils.trimToNull(m.getDepartmentName()));
|
||||
row.setRoleCode(DEFAULT_ROLE);
|
||||
row.setRoleLabel(DEFAULT_ROLE_LABEL);
|
||||
row.setNotifyEventException(0);
|
||||
row.setStatus("active");
|
||||
row.setCreateBy(op);
|
||||
row.setCreateTime(now);
|
||||
row.setUpdateBy(op);
|
||||
row.setUpdateTime(now);
|
||||
save(row);
|
||||
created++;
|
||||
} else {
|
||||
// 只刷新展示字段;岗位/线路/提醒开关保留运营手工配置
|
||||
existing.setName(StringUtils.defaultIfBlank(m.getName(), existing.getName()));
|
||||
if (StringUtils.isNotBlank(m.getMobile())) {
|
||||
existing.setMobile(m.getMobile());
|
||||
}
|
||||
if (StringUtils.isNotBlank(m.getDepartmentName())) {
|
||||
existing.setDepartName(m.getDepartmentName());
|
||||
}
|
||||
existing.setUpdateBy(op);
|
||||
existing.setUpdateTime(now);
|
||||
updateById(existing);
|
||||
updated++;
|
||||
}
|
||||
}
|
||||
|
||||
StaffWecomSyncResult result = new StaffWecomSyncResult();
|
||||
result.setFetched(members.size());
|
||||
result.setCreated(created);
|
||||
result.setUpdated(updated);
|
||||
result.setSkipped(skipped);
|
||||
result.setMessage(String.format(
|
||||
"同步完成:企微成员 %d,新建 %d,更新 %d,跳过 %d",
|
||||
members.size(), created, updated, skipped));
|
||||
log.info("企微通讯录同步 operator={} {}", op, result.getMessage());
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+59
-1
@@ -12,14 +12,17 @@ import org.jeecg.modules.inquiry.dto.TicketTransitionRequest;
|
||||
import org.jeecg.modules.inquiry.entity.InquiryAttachment;
|
||||
import org.jeecg.modules.inquiry.entity.InquiryExceptionCase;
|
||||
import org.jeecg.modules.inquiry.entity.InquiryQuote;
|
||||
import org.jeecg.modules.inquiry.entity.InquiryStaffRole;
|
||||
import org.jeecg.modules.inquiry.entity.InquiryTicket;
|
||||
import org.jeecg.modules.inquiry.entity.InquiryTicketStatusLog;
|
||||
import org.jeecg.modules.inquiry.mapper.InquiryAttachmentMapper;
|
||||
import org.jeecg.modules.inquiry.mapper.InquiryExceptionCaseMapper;
|
||||
import org.jeecg.modules.inquiry.mapper.InquiryQuoteMapper;
|
||||
import org.jeecg.modules.inquiry.mapper.InquiryStaffRoleMapper;
|
||||
import org.jeecg.modules.inquiry.mapper.InquiryTicketMapper;
|
||||
import org.jeecg.modules.inquiry.mapper.InquiryTicketStatusLogMapper;
|
||||
import org.jeecg.modules.inquiry.service.IInquiryWorkOrderService;
|
||||
import org.jeecg.modules.inquiry.support.InquiryTicketDisplaySupport;
|
||||
import org.jeecg.modules.inquiry.tms.InquiryTmsQueryService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -29,16 +32,21 @@ import com.alibaba.fastjson.JSONObject;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.Date;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 询价工单主账实现。
|
||||
* <p>
|
||||
* 副作用:改 inq_ticket.status、写 inq_ticket_status_log、改 inq_exception_case。
|
||||
* 列表/详情会就地补展示字段(salesName、demandTitle/Detail),只影响响应,不写回库。
|
||||
* 线程:单请求事务;只锁当前工单行(DB 行级),不串行其它工单。
|
||||
* 为何:Graph/前端成功不能冒充主账六态成功,所有 transition 必须过本服务。
|
||||
*/
|
||||
@@ -56,6 +64,8 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
|
||||
private InquiryExceptionCaseMapper exceptionCaseMapper;
|
||||
@Autowired
|
||||
private InquiryTmsQueryService tmsQueryService;
|
||||
@Autowired
|
||||
private InquiryStaffRoleMapper staffRoleMapper;
|
||||
|
||||
@Override
|
||||
public IPage<InquiryTicket> pageView(Page<InquiryTicket> page, Map<String, String> filters) {
|
||||
@@ -64,6 +74,7 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
|
||||
if (StringUtils.isNotBlank(keyword)) {
|
||||
qw.and(w -> w.like(InquiryTicket::getWorkOrderNo, keyword)
|
||||
.or().like(InquiryTicket::getSalesName, keyword)
|
||||
.or().like(InquiryTicket::getSalesWecomId, keyword)
|
||||
.or().like(InquiryTicket::getDemandTitle, keyword)
|
||||
.or().like(InquiryTicket::getOrigin, keyword)
|
||||
.or().like(InquiryTicket::getDestination, keyword));
|
||||
@@ -100,7 +111,9 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
|
||||
qw.le(InquiryTicket::getCreateTime, dateTo + " 23:59:59");
|
||||
}
|
||||
qw.orderByDesc(InquiryTicket::getCreateTime);
|
||||
return page(page, qw);
|
||||
IPage<InquiryTicket> result = page(page, qw);
|
||||
enrichDisplayFields(result.getRecords());
|
||||
return result;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -109,6 +122,7 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
|
||||
if (ticket == null) {
|
||||
throw new IllegalArgumentException("工单不存在: " + workOrderNo);
|
||||
}
|
||||
enrichDisplayFields(Collections.singletonList(ticket));
|
||||
List<InquiryQuote> quotes = quoteMapper.selectList(new LambdaQueryWrapper<InquiryQuote>()
|
||||
.eq(InquiryQuote::getWorkOrderNo, workOrderNo)
|
||||
.orderByAsc(InquiryQuote::getCreateTime));
|
||||
@@ -131,6 +145,50 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 列表/详情响应补齐:发起人姓名(员工表)+ 询价需求摘要(结构化字段)。
|
||||
* 副作用:只改内存中的 ticket 展示字段,不 updateById。
|
||||
*/
|
||||
private void enrichDisplayFields(List<InquiryTicket> tickets) {
|
||||
if (tickets == null || tickets.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
Map<String, String> staffNameByWecom = loadStaffNames(tickets);
|
||||
for (InquiryTicket ticket : tickets) {
|
||||
if (ticket == null) {
|
||||
continue;
|
||||
}
|
||||
String staffName = staffNameByWecom.get(StringUtils.trimToEmpty(ticket.getSalesWecomId()));
|
||||
ticket.setSalesName(InquiryTicketDisplaySupport.resolveSalesDisplayName(
|
||||
ticket.getSalesName(), ticket.getSalesWecomId(), staffName));
|
||||
InquiryTicketDisplaySupport.applyRequirementSummary(ticket);
|
||||
}
|
||||
}
|
||||
|
||||
/** 批量按企微 userid 取员工姓名,避免列表 N+1。 */
|
||||
private Map<String, String> loadStaffNames(List<InquiryTicket> tickets) {
|
||||
Set<String> wecomIds = new HashSet<>();
|
||||
for (InquiryTicket ticket : tickets) {
|
||||
if (ticket != null && StringUtils.isNotBlank(ticket.getSalesWecomId())) {
|
||||
wecomIds.add(ticket.getSalesWecomId().trim());
|
||||
}
|
||||
}
|
||||
if (wecomIds.isEmpty()) {
|
||||
return Collections.emptyMap();
|
||||
}
|
||||
List<InquiryStaffRole> staffRows = staffRoleMapper.selectList(new LambdaQueryWrapper<InquiryStaffRole>()
|
||||
.in(InquiryStaffRole::getWecomId, new ArrayList<>(wecomIds))
|
||||
.eq(InquiryStaffRole::getStatus, "active"));
|
||||
Map<String, String> result = new HashMap<>();
|
||||
for (InquiryStaffRole row : staffRows) {
|
||||
if (row == null || StringUtils.isBlank(row.getWecomId()) || StringUtils.isBlank(row.getName())) {
|
||||
continue;
|
||||
}
|
||||
result.put(row.getWecomId().trim(), row.getName().trim());
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public InquiryTicket transition(TicketTransitionRequest req, String operator) {
|
||||
|
||||
+222
@@ -0,0 +1,222 @@
|
||||
package org.jeecg.modules.inquiry.support;
|
||||
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.jeecg.modules.inquiry.entity.InquiryTicket;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* 工单列表/详情的展示口径,对齐现网 InquiryAggregateService.requirementSummaryLines。
|
||||
* <p>
|
||||
* 职责:把结构化字段拼成「发起人可读名 + 询价需求多行摘要」,禁止把 FP:AIR|… 原文甩到后台。
|
||||
* 副作用:无写库;仅生成展示用字符串。
|
||||
* 为何:inq_ticket.sales_name / demand_* 可能为空或被写入管道原文,后台必须以员工表与字段快照为准。
|
||||
*/
|
||||
public final class InquiryTicketDisplaySupport {
|
||||
|
||||
private InquiryTicketDisplaySupport() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 询价需求多行摘要:首行标题(业务线 · 路线),后续为货量/品名。
|
||||
* 与旧后台 requirementSummaryLines 同口径,便于对照验收。
|
||||
*/
|
||||
public static List<String> requirementSummaryLines(InquiryTicket ticket) {
|
||||
List<String> lines = new ArrayList<>();
|
||||
if (ticket == null) {
|
||||
return lines;
|
||||
}
|
||||
String businessLine = upper(ticket.getBusinessLine());
|
||||
|
||||
List<String> heading = new ArrayList<>();
|
||||
String lineLabel = firstText(ticket.getBizTypeLabel(), businessLineLabel(ticket.getBusinessLine()));
|
||||
if (StringUtils.isNotBlank(lineLabel)) {
|
||||
heading.add(lineLabel);
|
||||
}
|
||||
String route = route(ticket.getOrigin(), ticket.getDestination());
|
||||
if (StringUtils.isNotBlank(route)) {
|
||||
heading.add(route);
|
||||
}
|
||||
addLine(lines, join(heading, " · "));
|
||||
|
||||
List<String> quantities = new ArrayList<>();
|
||||
if ("AIR".equals(businessLine) || containsChinese(lineLabel, "空运")) {
|
||||
addLabel(quantities, "件数", ticket.getPieces());
|
||||
addUnit(quantities, "毛重", ticket.getWeightKg(), "kg");
|
||||
addUnit(quantities, "体积", ticket.getVolumeCbm(), "CBM");
|
||||
} else if ("SEA".equals(businessLine) || containsChinese(lineLabel, "海运")) {
|
||||
addLabel(quantities, "件数", ticket.getPieces());
|
||||
addUnit(quantities, "重量", ticket.getWeightKg(), "kg");
|
||||
addUnit(quantities, "体积", ticket.getVolumeCbm(), "CBM");
|
||||
} else if ("LAND".equals(businessLine) || containsChinese(lineLabel, "陆运")) {
|
||||
addUnit(quantities, "体积", ticket.getVolumeCbm(), "CBM");
|
||||
addUnit(quantities, "重量", ticket.getWeightKg(), "kg");
|
||||
addLabel(quantities, "件数", ticket.getPieces());
|
||||
} else {
|
||||
addLabel(quantities, "件数", ticket.getPieces());
|
||||
addUnit(quantities, "重量", ticket.getWeightKg(), "kg");
|
||||
addUnit(quantities, "体积", ticket.getVolumeCbm(), "CBM");
|
||||
}
|
||||
addLine(lines, join(quantities, " · "));
|
||||
|
||||
List<String> cargo = new ArrayList<>();
|
||||
boolean land = "LAND".equals(businessLine) || containsChinese(lineLabel, "陆运");
|
||||
addLabel(cargo, land ? "货物品名" : "品名", ticket.getGoodsName());
|
||||
if ("AIR".equals(businessLine) || containsChinese(lineLabel, "空运")) {
|
||||
addLabel(cargo, "客户", ticket.getCustomerName());
|
||||
}
|
||||
addLine(lines, join(cargo, " · "));
|
||||
return lines;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把摘要写回 ticket 的 demandTitle/demandDetail,仅用于 API 响应,不落库。
|
||||
*/
|
||||
public static void applyRequirementSummary(InquiryTicket ticket) {
|
||||
if (ticket == null) {
|
||||
return;
|
||||
}
|
||||
List<String> lines = requirementSummaryLines(ticket);
|
||||
if (lines.isEmpty()) {
|
||||
// 结构化字段全空时,仍禁止把 FP: 管道原文当需求展示
|
||||
if (looksLikeRawFp(ticket.getDemandTitle())) {
|
||||
ticket.setDemandTitle(null);
|
||||
}
|
||||
if (looksLikeRawFp(ticket.getDemandDetail())) {
|
||||
ticket.setDemandDetail(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
ticket.setDemandTitle(lines.get(0));
|
||||
if (lines.size() == 1) {
|
||||
ticket.setDemandDetail("");
|
||||
} else {
|
||||
ticket.setDemandDetail(join(lines.subList(1, lines.size()), "\n"));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发起人展示名:优先已有中文名,其次员工表姓名;企微 userid 不能冒充人名。
|
||||
*/
|
||||
public static String resolveSalesDisplayName(String salesName, String salesWecomId, String staffName) {
|
||||
if (StringUtils.isNotBlank(salesName) && !looksLikeWecomUserId(salesName)) {
|
||||
return salesName.trim();
|
||||
}
|
||||
if (StringUtils.isNotBlank(staffName)) {
|
||||
return staffName.trim();
|
||||
}
|
||||
if (StringUtils.isNotBlank(salesName)) {
|
||||
return salesName.trim();
|
||||
}
|
||||
return StringUtils.trimToEmpty(salesWecomId);
|
||||
}
|
||||
|
||||
public static boolean looksLikeRawFp(String value) {
|
||||
String raw = StringUtils.trimToEmpty(value);
|
||||
return raw.startsWith("FP:") || raw.contains("|") && raw.contains(":");
|
||||
}
|
||||
|
||||
/** 纯拼音/企微 userid(无中文)不当作「发起人名称」。 */
|
||||
public static boolean looksLikeWecomUserId(String value) {
|
||||
String raw = StringUtils.trimToEmpty(value);
|
||||
if (raw.isEmpty()) {
|
||||
return true;
|
||||
}
|
||||
if (raw.chars().anyMatch(ch -> Character.UnicodeScript.of(ch) == Character.UnicodeScript.HAN)) {
|
||||
return false;
|
||||
}
|
||||
return raw.matches("^[A-Za-z][A-Za-z0-9._-]{1,64}$");
|
||||
}
|
||||
|
||||
private static String businessLineLabel(String value) {
|
||||
if ("AIR".equalsIgnoreCase(value)) {
|
||||
return "空运";
|
||||
}
|
||||
if ("SEA".equalsIgnoreCase(value)) {
|
||||
return "海运";
|
||||
}
|
||||
if ("LAND".equalsIgnoreCase(value)) {
|
||||
return "陆运";
|
||||
}
|
||||
if ("MULTI".equalsIgnoreCase(value)) {
|
||||
return "多段联运";
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private static String route(String origin, String destination) {
|
||||
if (StringUtils.isBlank(origin) && StringUtils.isBlank(destination)) {
|
||||
return null;
|
||||
}
|
||||
return (StringUtils.isNotBlank(origin) ? origin.trim() : "-")
|
||||
+ " → "
|
||||
+ (StringUtils.isNotBlank(destination) ? destination.trim() : "-");
|
||||
}
|
||||
|
||||
private static void addLine(List<String> lines, String value) {
|
||||
if (StringUtils.isNotBlank(value)) {
|
||||
lines.add(value);
|
||||
}
|
||||
}
|
||||
|
||||
private static void addLabel(List<String> values, String label, Object value) {
|
||||
if (value == null || !StringUtils.isNotBlank(String.valueOf(value))) {
|
||||
return;
|
||||
}
|
||||
values.add(label + " " + plain(value));
|
||||
}
|
||||
|
||||
private static void addUnit(List<String> values, String label, Object value, String unit) {
|
||||
if (value == null || !StringUtils.isNotBlank(String.valueOf(value))) {
|
||||
return;
|
||||
}
|
||||
values.add(label + " " + plain(value) + unit);
|
||||
}
|
||||
|
||||
private static String plain(Object value) {
|
||||
if (value instanceof BigDecimal) {
|
||||
return ((BigDecimal) value).stripTrailingZeros().toPlainString();
|
||||
}
|
||||
return String.valueOf(value).trim();
|
||||
}
|
||||
|
||||
private static String join(List<String> values, String sep) {
|
||||
if (values == null || values.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (String v : values) {
|
||||
if (!StringUtils.isNotBlank(v)) {
|
||||
continue;
|
||||
}
|
||||
if (sb.length() > 0) {
|
||||
sb.append(sep);
|
||||
}
|
||||
sb.append(v.trim());
|
||||
}
|
||||
return sb.length() == 0 ? null : sb.toString();
|
||||
}
|
||||
|
||||
private static String firstText(String... values) {
|
||||
if (values == null) {
|
||||
return null;
|
||||
}
|
||||
for (String v : values) {
|
||||
if (StringUtils.isNotBlank(v)) {
|
||||
return v.trim();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static String upper(String value) {
|
||||
return value == null ? "" : value.trim().toUpperCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
private static boolean containsChinese(String value, String token) {
|
||||
return StringUtils.isNotBlank(value) && value.contains(token);
|
||||
}
|
||||
}
|
||||
+287
@@ -0,0 +1,287 @@
|
||||
package org.jeecg.modules.inquiry.wecom;
|
||||
|
||||
import com.alibaba.fastjson.JSONArray;
|
||||
import com.alibaba.fastjson.JSONObject;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.client.SimpleClientHttpRequestFactory;
|
||||
import org.springframework.http.converter.StringHttpMessageConverter;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
import org.springframework.web.util.UriComponentsBuilder;
|
||||
|
||||
import javax.annotation.PostConstruct;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.Proxy;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 企微通讯录只读客户端(主账侧)。
|
||||
* <p>
|
||||
* 职责:gettoken + department/list + user/list,给员工页同步姓名/手机/部门。
|
||||
* 密钥:环境变量 WECOM_CORP_ID / WECOM_SECRET(与询价应用 1000010 同一套,见 prompt/03)。
|
||||
* 本机开发可设 WECOM_SOCKS_HOST/PORT,经测试机 SOCKS 出口,避免本机公网 IP 不在企微白名单(errcode=60020)。
|
||||
* 禁止:浏览器直连;禁止在同步失败时挡询价主路径;禁止把 Secret 写入日志。
|
||||
* 线程:HTTP 请求线程调用;token 缓存带锁;connect/read 超时,禁止无限阻塞。
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class WeComAddressBookClient {
|
||||
|
||||
private static final String DEFAULT_API_BASE = "https://qyapi.weixin.qq.com";
|
||||
/** 根部门 id;fetch_child=1 拉全树成员。 */
|
||||
private static final int ROOT_DEPARTMENT_ID = 1;
|
||||
|
||||
@Value("${WECOM_CORP_ID:}")
|
||||
private String corpId;
|
||||
|
||||
@Value("${WECOM_SECRET:}")
|
||||
private String secret;
|
||||
|
||||
@Value("${WECOM_API_BASE:" + DEFAULT_API_BASE + "}")
|
||||
private String apiBase;
|
||||
|
||||
/** 可选:本机开发经 SOCKS 出站(如 127.0.0.1,配合 ssh -D)。 */
|
||||
@Value("${WECOM_SOCKS_HOST:}")
|
||||
private String socksHost;
|
||||
|
||||
@Value("${WECOM_SOCKS_PORT:1080}")
|
||||
private int socksPort;
|
||||
|
||||
private RestTemplate restTemplate;
|
||||
private final Object tokenLock = new Object();
|
||||
private String cachedToken;
|
||||
private long tokenExpireAtMs;
|
||||
|
||||
@PostConstruct
|
||||
public void initHttpClient() {
|
||||
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
|
||||
factory.setConnectTimeout(5_000);
|
||||
factory.setReadTimeout(15_000);
|
||||
if (StringUtils.isNotBlank(socksHost)) {
|
||||
factory.setProxy(new Proxy(Proxy.Type.SOCKS, new InetSocketAddress(socksHost.trim(), socksPort)));
|
||||
log.info("企微 HTTP 经 SOCKS {}:{} 出站(绕过本机 IP 白名单限制)", socksHost, socksPort);
|
||||
}
|
||||
RestTemplate rt = new RestTemplate(factory);
|
||||
rt.getMessageConverters().set(1, new StringHttpMessageConverter(StandardCharsets.UTF_8));
|
||||
this.restTemplate = rt;
|
||||
}
|
||||
|
||||
/**
|
||||
* 配置是否齐全(不访问外网)。
|
||||
*/
|
||||
public boolean isConfigured() {
|
||||
return StringUtils.isNotBlank(corpId) && StringUtils.isNotBlank(secret);
|
||||
}
|
||||
|
||||
/**
|
||||
* 拉取应用可见范围内的已激活成员(去重 userid)。
|
||||
* <p>
|
||||
* 副作用:出站 HTTP 调企微;失败抛 IllegalStateException,由上层转 Result。
|
||||
*/
|
||||
public List<WeComMember> listActiveMembers() {
|
||||
if (!isConfigured()) {
|
||||
throw new IllegalStateException("未配置 WECOM_CORP_ID / WECOM_SECRET,无法同步通讯录");
|
||||
}
|
||||
String token = getAccessToken();
|
||||
Map<Integer, String> deptNames = loadDepartmentNames(token);
|
||||
String url = UriComponentsBuilder
|
||||
.fromHttpUrl(trimBase(apiBase) + "/cgi-bin/user/list")
|
||||
.queryParam("access_token", token)
|
||||
.queryParam("department_id", ROOT_DEPARTMENT_ID)
|
||||
.queryParam("fetch_child", 1)
|
||||
.toUriString();
|
||||
JSONObject body = getJson(url);
|
||||
assertOk(body, "user/list");
|
||||
JSONArray userlist = body.getJSONArray("userlist");
|
||||
Map<String, WeComMember> byUserid = new LinkedHashMap<>();
|
||||
if (userlist == null) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
for (int i = 0; i < userlist.size(); i++) {
|
||||
JSONObject u = userlist.getJSONObject(i);
|
||||
if (u == null) {
|
||||
continue;
|
||||
}
|
||||
String userid = StringUtils.trimToEmpty(u.getString("userid"));
|
||||
if (userid.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
int status = u.getIntValue("status");
|
||||
// 1=已激活;其它状态不同步进业务表,避免半离职账号入主账
|
||||
if (status != 1) {
|
||||
continue;
|
||||
}
|
||||
String name = StringUtils.defaultIfBlank(u.getString("name"), userid);
|
||||
String mobile = StringUtils.trimToEmpty(u.getString("mobile"));
|
||||
String deptName = resolveDepartmentName(u.getJSONArray("department"), deptNames);
|
||||
byUserid.put(userid, new WeComMember(userid, name, mobile, deptName, status));
|
||||
}
|
||||
return new ArrayList<>(byUserid.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* 拉取企微部门树(扁平列表,带 path 便于下拉展示)。
|
||||
* <p>
|
||||
* 副作用:出站 HTTP;失败抛 IllegalStateException。
|
||||
*/
|
||||
public List<WeComDepartment> listDepartments() {
|
||||
if (!isConfigured()) {
|
||||
throw new IllegalStateException("未配置 WECOM_CORP_ID / WECOM_SECRET,无法拉取部门");
|
||||
}
|
||||
String token = getAccessToken();
|
||||
String url = UriComponentsBuilder
|
||||
.fromHttpUrl(trimBase(apiBase) + "/cgi-bin/department/list")
|
||||
.queryParam("access_token", token)
|
||||
.toUriString();
|
||||
JSONObject body = getJson(url);
|
||||
assertOk(body, "department/list");
|
||||
JSONArray list = body.getJSONArray("department");
|
||||
if (list == null || list.isEmpty()) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
|
||||
Map<Integer, JSONObject> byId = new LinkedHashMap<>();
|
||||
for (int i = 0; i < list.size(); i++) {
|
||||
JSONObject d = list.getJSONObject(i);
|
||||
if (d != null) {
|
||||
byId.put(d.getIntValue("id"), d);
|
||||
}
|
||||
}
|
||||
|
||||
List<WeComDepartment> result = new ArrayList<>();
|
||||
for (JSONObject d : byId.values()) {
|
||||
int id = d.getIntValue("id");
|
||||
String name = StringUtils.defaultIfBlank(d.getString("name"), String.valueOf(id));
|
||||
int parentId = d.getIntValue("parentid");
|
||||
String path = buildDepartmentPath(id, byId);
|
||||
result.add(new WeComDepartment(id, name, parentId, path));
|
||||
}
|
||||
// 按路径排序,下拉更易扫
|
||||
result.sort((a, b) -> StringUtils.defaultString(a.getPath()).compareTo(StringUtils.defaultString(b.getPath())));
|
||||
return result;
|
||||
}
|
||||
|
||||
private String buildDepartmentPath(int id, Map<Integer, JSONObject> byId) {
|
||||
List<String> parts = new ArrayList<>();
|
||||
int cursor = id;
|
||||
// 防环:最多 32 层
|
||||
for (int depth = 0; depth < 32; depth++) {
|
||||
JSONObject d = byId.get(cursor);
|
||||
if (d == null) {
|
||||
break;
|
||||
}
|
||||
parts.add(0, StringUtils.defaultIfBlank(d.getString("name"), String.valueOf(cursor)));
|
||||
int parentId = d.getIntValue("parentid");
|
||||
if (parentId <= 0 || parentId == cursor || !byId.containsKey(parentId)) {
|
||||
break;
|
||||
}
|
||||
cursor = parentId;
|
||||
}
|
||||
return String.join(" / ", parts);
|
||||
}
|
||||
|
||||
private Map<Integer, String> loadDepartmentNames(String token) {
|
||||
String url = UriComponentsBuilder
|
||||
.fromHttpUrl(trimBase(apiBase) + "/cgi-bin/department/list")
|
||||
.queryParam("access_token", token)
|
||||
.toUriString();
|
||||
JSONObject body = getJson(url);
|
||||
assertOk(body, "department/list");
|
||||
Map<Integer, String> map = new HashMap<>();
|
||||
JSONArray list = body.getJSONArray("department");
|
||||
if (list == null) {
|
||||
return map;
|
||||
}
|
||||
for (int i = 0; i < list.size(); i++) {
|
||||
JSONObject d = list.getJSONObject(i);
|
||||
if (d == null) {
|
||||
continue;
|
||||
}
|
||||
map.put(d.getIntValue("id"), StringUtils.defaultString(d.getString("name")));
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
private String resolveDepartmentName(JSONArray departmentIds, Map<Integer, String> deptNames) {
|
||||
if (departmentIds == null || departmentIds.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
List<String> names = new ArrayList<>();
|
||||
for (int i = 0; i < departmentIds.size(); i++) {
|
||||
int id = departmentIds.getIntValue(i);
|
||||
String n = deptNames.get(id);
|
||||
if (StringUtils.isNotBlank(n)) {
|
||||
names.add(n);
|
||||
}
|
||||
}
|
||||
return String.join("/", names);
|
||||
}
|
||||
|
||||
private String getAccessToken() {
|
||||
synchronized (tokenLock) {
|
||||
long now = System.currentTimeMillis();
|
||||
if (StringUtils.isNotBlank(cachedToken) && now < tokenExpireAtMs) {
|
||||
return cachedToken;
|
||||
}
|
||||
String url = UriComponentsBuilder
|
||||
.fromHttpUrl(trimBase(apiBase) + "/cgi-bin/gettoken")
|
||||
.queryParam("corpid", corpId)
|
||||
.queryParam("corpsecret", secret)
|
||||
.toUriString();
|
||||
JSONObject body = getJson(url);
|
||||
assertOk(body, "gettoken");
|
||||
String token = body.getString("access_token");
|
||||
if (StringUtils.isBlank(token)) {
|
||||
throw new IllegalStateException("企微 gettoken 未返回 access_token");
|
||||
}
|
||||
int expiresIn = body.getIntValue("expires_in");
|
||||
if (expiresIn <= 0) {
|
||||
expiresIn = 7200;
|
||||
}
|
||||
// 提前 5 分钟失效,避免边界过期
|
||||
cachedToken = token;
|
||||
tokenExpireAtMs = now + Math.max(60, expiresIn - 300) * 1000L;
|
||||
log.info("企微 access_token 已刷新,expiresIn={}s", expiresIn);
|
||||
return cachedToken;
|
||||
}
|
||||
}
|
||||
|
||||
private JSONObject getJson(String url) {
|
||||
try {
|
||||
String raw = restTemplate.getForObject(url, String.class);
|
||||
if (StringUtils.isBlank(raw)) {
|
||||
throw new IllegalStateException("企微接口返回空响应");
|
||||
}
|
||||
return JSONObject.parseObject(raw);
|
||||
} catch (IllegalStateException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException("调用企微失败: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
private void assertOk(JSONObject body, String api) {
|
||||
if (body == null) {
|
||||
throw new IllegalStateException("企微 " + api + " 响应为空");
|
||||
}
|
||||
int errcode = body.getIntValue("errcode");
|
||||
if (errcode != 0) {
|
||||
String errmsg = body.getString("errmsg");
|
||||
throw new IllegalStateException("企微 " + api + " 失败 errcode=" + errcode + " errmsg=" + errmsg);
|
||||
}
|
||||
}
|
||||
|
||||
private static String trimBase(String base) {
|
||||
if (StringUtils.isBlank(base)) {
|
||||
return DEFAULT_API_BASE;
|
||||
}
|
||||
return base.endsWith("/") ? base.substring(0, base.length() - 1) : base;
|
||||
}
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
package org.jeecg.modules.inquiry.wecom;
|
||||
|
||||
/**
|
||||
* 企微部门投影,供员工表单下拉。
|
||||
*/
|
||||
public class WeComDepartment {
|
||||
|
||||
private final int id;
|
||||
private final String name;
|
||||
private final int parentId;
|
||||
/** 自根到本节点的展示路径,如「友通达 / 销售部」。 */
|
||||
private final String path;
|
||||
|
||||
public WeComDepartment(int id, String name, int parentId, String path) {
|
||||
this.id = id;
|
||||
this.name = name;
|
||||
this.parentId = parentId;
|
||||
this.path = path;
|
||||
}
|
||||
|
||||
public int getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public int getParentId() {
|
||||
return parentId;
|
||||
}
|
||||
|
||||
public String getPath() {
|
||||
return path;
|
||||
}
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
package org.jeecg.modules.inquiry.wecom;
|
||||
|
||||
/**
|
||||
* 企微通讯录成员投影(仅姓名展示所需字段)。
|
||||
* <p>
|
||||
* 来源:qyapi user/list;不做岗位推断,岗位仍由运营在后台维护。
|
||||
*/
|
||||
public class WeComMember {
|
||||
|
||||
private final String userid;
|
||||
private final String name;
|
||||
private final String mobile;
|
||||
private final String departmentName;
|
||||
private final int status;
|
||||
|
||||
public WeComMember(String userid, String name, String mobile, String departmentName, int status) {
|
||||
this.userid = userid;
|
||||
this.name = name;
|
||||
this.mobile = mobile;
|
||||
this.departmentName = departmentName;
|
||||
this.status = status;
|
||||
}
|
||||
|
||||
public String getUserid() {
|
||||
return userid;
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public String getMobile() {
|
||||
return mobile;
|
||||
}
|
||||
|
||||
public String getDepartmentName() {
|
||||
return departmentName;
|
||||
}
|
||||
|
||||
public int getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
/** 企微 status=1 表示已激活,可入员工表。 */
|
||||
public boolean isActive() {
|
||||
return status == 1;
|
||||
}
|
||||
}
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
package org.jeecg.modules.inquiry.support;
|
||||
|
||||
import org.jeecg.modules.inquiry.entity.InquiryTicket;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* 工单列表展示口径:发起人名称 + 询价需求摘要,对齐旧后台。
|
||||
*/
|
||||
public class InquiryTicketDisplaySupportTest {
|
||||
|
||||
@Test
|
||||
public void airRequirementSummaryMatchesLegacyShape() {
|
||||
InquiryTicket ticket = new InquiryTicket();
|
||||
ticket.setBusinessLine("AIR");
|
||||
ticket.setBizTypeLabel("空运");
|
||||
ticket.setOrigin("ZUH");
|
||||
ticket.setDestination("CRK");
|
||||
ticket.setPieces(10);
|
||||
ticket.setWeightKg(new BigDecimal("1"));
|
||||
ticket.setVolumeCbm(new BigDecimal("8"));
|
||||
ticket.setGoodsName("茶叶");
|
||||
ticket.setDemandDetail("FP:AIR|ZUH|CRK|茶叶|10件|1KGS|8CBM|2026-09-11");
|
||||
|
||||
List<String> lines = InquiryTicketDisplaySupport.requirementSummaryLines(ticket);
|
||||
assertEquals("空运 · ZUH → CRK", lines.get(0));
|
||||
assertEquals("件数 10 · 毛重 1kg · 体积 8CBM", lines.get(1));
|
||||
assertEquals("品名 茶叶", lines.get(2));
|
||||
|
||||
InquiryTicketDisplaySupport.applyRequirementSummary(ticket);
|
||||
assertEquals("空运 · ZUH → CRK", ticket.getDemandTitle());
|
||||
assertEquals("件数 10 · 毛重 1kg · 体积 8CBM\n品名 茶叶", ticket.getDemandDetail());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void landUsesGoodsNameLabel() {
|
||||
InquiryTicket ticket = new InquiryTicket();
|
||||
ticket.setBusinessLine("LAND");
|
||||
ticket.setBizTypeLabel("陆运");
|
||||
ticket.setOrigin("上海");
|
||||
ticket.setDestination("北宁Bac Ninh");
|
||||
ticket.setGoodsName("设备");
|
||||
|
||||
List<String> lines = InquiryTicketDisplaySupport.requirementSummaryLines(ticket);
|
||||
assertEquals("陆运 · 上海 → 北宁Bac Ninh", lines.get(0));
|
||||
assertEquals("货物品名 设备", lines.get(1));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void salesNamePrefersStaffChineseName() {
|
||||
assertEquals("吴吉林",
|
||||
InquiryTicketDisplaySupport.resolveSalesDisplayName(null, "WuJiLin", "吴吉林"));
|
||||
assertEquals("陈柏成",
|
||||
InquiryTicketDisplaySupport.resolveSalesDisplayName("WuJiLin", "WuJiLin", "陈柏成"));
|
||||
assertFalse(InquiryTicketDisplaySupport.looksLikeWecomUserId("陈柏成"));
|
||||
assertTrue(InquiryTicketDisplaySupport.looksLikeWecomUserId("WuJiLin"));
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 员工管理:对接主账 /inquiry/staffRole。
|
||||
* 企微 userid 由运营手工绑定,不向企微换号;停用走 disable 接口。
|
||||
* 企微 userid 可手工维护,也可点「同步企业微信」由主账 upsert(姓名/手机/部门)。
|
||||
* 浏览器禁止直连企微;岗位/线路仍手工改。
|
||||
*/
|
||||
import { computed, onMounted, reactive, ref, watch } from "vue";
|
||||
import { message, Modal } from "ant-design-vue";
|
||||
@@ -9,13 +10,17 @@ import {
|
||||
disableStaff,
|
||||
getStaffCounts,
|
||||
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";
|
||||
|
||||
type StaffRole = "sales" | "air" | "sea" | "land";
|
||||
|
||||
type WecomDeptOption = { id: number; name: string; path: string; parentId: number; depth: number };
|
||||
|
||||
type StaffRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -53,6 +58,10 @@ const filters = reactive({
|
||||
|
||||
const modalOpen = ref(false);
|
||||
const editing = ref<StaffRow | null>(null);
|
||||
const syncing = ref(false);
|
||||
const wecomDepts = ref<WecomDeptOption[]>([]);
|
||||
const wecomDeptsLoading = ref(false);
|
||||
const wecomDeptsLoaded = ref(false);
|
||||
const form = reactive({
|
||||
name: "",
|
||||
wecomId: "",
|
||||
@@ -139,6 +148,82 @@ watch(
|
||||
|
||||
onMounted(() => loadList());
|
||||
|
||||
/**
|
||||
* 打开弹窗时拉企微部门;失败不挡手工保存,仅提示。
|
||||
* 下拉对齐旧项目业务层:只展示「分公司」「旧组织」下的直接子部门
|
||||
*(事业二部/元昇/武汉友通达/通多邦…),不要公司根下的一级中心。
|
||||
*/
|
||||
async function ensureWecomDepartments() {
|
||||
if (wecomDeptsLoaded.value || wecomDeptsLoading.value) {
|
||||
ensureCurrentDeptOption();
|
||||
return;
|
||||
}
|
||||
wecomDeptsLoading.value = true;
|
||||
try {
|
||||
const res: any = await getWecomDepartments();
|
||||
const list = Array.isArray(res) ? res : res?.result || [];
|
||||
const all: WecomDeptOption[] = (list as any[])
|
||||
.map((d) => {
|
||||
const path = String(d.path || d.name || "");
|
||||
const name = String(d.name || path);
|
||||
const parentId = Number(d.parentId ?? d.parentid ?? 0);
|
||||
const depth = path ? path.split(" / ").length - 1 : 0;
|
||||
return {
|
||||
id: Number(d.id),
|
||||
name,
|
||||
path,
|
||||
parentId,
|
||||
depth: depth < 0 ? 0 : depth,
|
||||
};
|
||||
})
|
||||
.filter((d) => d.name && d.id);
|
||||
|
||||
// 旧项目下拉对应层:分公司 + 旧组织 的子节点
|
||||
const businessParentNames = new Set(["分公司", "旧组织"]);
|
||||
const businessParentIds = new Set(
|
||||
all.filter((d) => businessParentNames.has(d.name)).map((d) => d.id),
|
||||
);
|
||||
let picked = all.filter((d) => businessParentIds.has(d.parentId));
|
||||
|
||||
// 兜底:若企微改名导致找不到父节点,仍回退到根下一级,避免空下拉
|
||||
if (!picked.length) {
|
||||
const rootIds = new Set(
|
||||
all.filter((d) => d.parentId === 0 || d.depth === 0).map((d) => d.id),
|
||||
);
|
||||
if (!rootIds.size) {
|
||||
const minDepth = Math.min(...all.map((d) => d.depth));
|
||||
all.filter((d) => d.depth === minDepth).forEach((d) => rootIds.add(d.id));
|
||||
}
|
||||
picked = all.filter((d) => rootIds.has(d.parentId));
|
||||
}
|
||||
|
||||
wecomDepts.value = picked
|
||||
.slice()
|
||||
.sort((a, b) => a.name.localeCompare(b.name, "zh-CN"))
|
||||
.map((d) => ({ ...d, depth: 0 }));
|
||||
wecomDeptsLoaded.value = true;
|
||||
} catch (e: any) {
|
||||
message.warning(e?.message || "企微部门列表加载失败,可稍后重试");
|
||||
} finally {
|
||||
wecomDeptsLoading.value = false;
|
||||
ensureCurrentDeptOption();
|
||||
}
|
||||
}
|
||||
|
||||
function ensureCurrentDeptOption() {
|
||||
const cur = (form.dept || "").trim();
|
||||
if (!cur) return;
|
||||
const hit = wecomDepts.value.some((d) => d.name === cur || d.path === cur);
|
||||
if (!hit) {
|
||||
wecomDepts.value = [{ id: -1, name: cur, path: cur, parentId: 0, depth: 0 }, ...wecomDepts.value];
|
||||
} else {
|
||||
const byPath = wecomDepts.value.find((d) => d.path === cur);
|
||||
if (byPath && form.dept !== byPath.name) {
|
||||
form.dept = byPath.name;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
editing.value = null;
|
||||
Object.assign(form, {
|
||||
@@ -152,6 +237,9 @@ function openCreate() {
|
||||
status: "active",
|
||||
});
|
||||
modalOpen.value = true;
|
||||
// 每次打开重新拉一级部门,避免会话里还留着旧的「整棵树」缓存
|
||||
wecomDeptsLoaded.value = false;
|
||||
ensureWecomDepartments();
|
||||
}
|
||||
|
||||
function openEdit(row: StaffRow) {
|
||||
@@ -167,6 +255,8 @@ function openEdit(row: StaffRow) {
|
||||
status: row.status,
|
||||
});
|
||||
modalOpen.value = true;
|
||||
wecomDeptsLoaded.value = false;
|
||||
ensureWecomDepartments();
|
||||
}
|
||||
|
||||
async function saveStaff() {
|
||||
@@ -213,8 +303,19 @@ function removeStaff(row: StaffRow) {
|
||||
});
|
||||
}
|
||||
|
||||
function syncWecom() {
|
||||
message.info("企微通讯录同步尚未开放,请手工维护企微 ID");
|
||||
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) {
|
||||
@@ -229,11 +330,11 @@ function routeTags(routes: string) {
|
||||
<template>
|
||||
<Teleport defer to="#page-topbar-actions">
|
||||
<div class="staff-topbar-actions">
|
||||
<button type="button" class="btn btn-secondary" @click="syncWecom">
|
||||
<ProtoIcon name="refresh" :size="16" />同步企业微信
|
||||
<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" />新建员工
|
||||
<ProtoIcon name="userPlus" :size="16" />新增员工
|
||||
</button>
|
||||
</div>
|
||||
</Teleport>
|
||||
@@ -363,29 +464,32 @@ function routeTags(routes: string) {
|
||||
|
||||
<AdminModal
|
||||
v-model:open="modalOpen"
|
||||
:title="editing ? '编辑员工' : '新建员工'"
|
||||
:title="editing ? '编辑员工' : '新增员工'"
|
||||
size="md"
|
||||
ok-text="保存"
|
||||
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">企微 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>
|
||||
<input v-model="form.phone" class="field" type="tel" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label class="lbl">部门</label>
|
||||
<input v-model="form.dept" class="field" type="text" />
|
||||
<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>
|
||||
<label class="lbl">所属部门</label>
|
||||
<select v-model="form.dept" class="field" :disabled="wecomDeptsLoading">
|
||||
<option value="">{{ wecomDeptsLoading ? "加载企微部门…" : "请选择部门" }}</option>
|
||||
<option v-for="d in wecomDepts" :key="d.id + '-' + d.name" :value="d.name">{{ d.name }}</option>
|
||||
</select>
|
||||
</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>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* 员工岗位 API:对接主账 /inquiry/staffRole。
|
||||
* 与 Jeecg /sys/user 分离——本页管企微岗位绑定。
|
||||
* 与 Jeecg /sys/user 分离——本页管企微岗位绑定;同步走主账 /syncWecom,浏览器不直连企微。
|
||||
*/
|
||||
import { defHttp } from '/@/utils/http/axios';
|
||||
|
||||
@@ -11,6 +11,8 @@ enum Api {
|
||||
edit = '/inquiry/staffRole/edit',
|
||||
disable = '/inquiry/staffRole/disable',
|
||||
queryById = '/inquiry/staffRole/queryById',
|
||||
syncWecom = '/inquiry/staffRole/syncWecom',
|
||||
wecomDepartments = '/inquiry/staffRole/wecomDepartments',
|
||||
}
|
||||
|
||||
export const getStaffList = (params) => defHttp.get({ url: Api.list, params });
|
||||
@@ -19,3 +21,7 @@ export const saveStaff = (data, isUpdate: boolean) =>
|
||||
isUpdate ? defHttp.put({ url: Api.edit, data }) : defHttp.post({ url: Api.add, data });
|
||||
export const disableStaff = (id: string) => defHttp.post({ url: Api.disable, params: { id } });
|
||||
export const getStaffById = (id: string) => defHttp.get({ url: Api.queryById, params: { id } });
|
||||
/** 主账拉企微通讯录 upsert;返回 { fetched, created, updated, skipped, message } */
|
||||
export const syncWecomStaff = () => defHttp.post({ url: Api.syncWecom });
|
||||
/** 企微部门下拉(主账转发,禁止浏览器直连企微) */
|
||||
export const getWecomDepartments = () => defHttp.get({ url: Api.wecomDepartments });
|
||||
|
||||
@@ -72,6 +72,37 @@ function dash(v: any) {
|
||||
return s ? s : "-";
|
||||
}
|
||||
|
||||
/**
|
||||
* 主账 inq_ticket_status_log.action 是内部码,禁止当备注展示。
|
||||
* transition 与「已报价」等 toStatus 重复,无用户备注时不再跟一段英文。
|
||||
*/
|
||||
const STATUS_LOG_ACTION_LABEL: Record<string, string> = {
|
||||
transition: "",
|
||||
retry: "系统重试",
|
||||
abnormal_handle: "异常已处理",
|
||||
};
|
||||
|
||||
function isInternalActionCode(v: string) {
|
||||
return Object.prototype.hasOwnProperty.call(STATUS_LOG_ACTION_LABEL, v);
|
||||
}
|
||||
|
||||
/**
|
||||
* 时间线第二段:优先用户备注;否则把内部 action 译成中文;纯内部码且无文案则不展示。
|
||||
*/
|
||||
function timelineNote(x: any) {
|
||||
const remark = String(x?.remark ?? "").trim();
|
||||
if (remark && !isInternalActionCode(remark)) {
|
||||
return remark;
|
||||
}
|
||||
const action = String(x?.action ?? "").trim();
|
||||
const key = isInternalActionCode(remark) ? remark : action;
|
||||
if (isInternalActionCode(key)) {
|
||||
const label = STATUS_LOG_ACTION_LABEL[key];
|
||||
return label ? label : "-";
|
||||
}
|
||||
return dash(action);
|
||||
}
|
||||
|
||||
function fmtTime(v: any) {
|
||||
const s = (v ?? "").toString().trim().replace("T", " ");
|
||||
return s ? s.slice(0, 19) : "-";
|
||||
@@ -83,6 +114,90 @@ function fmtMeasure(v: any, unit: string) {
|
||||
return s ? `${s} ${unit}` : "-";
|
||||
}
|
||||
|
||||
/** 业务线英文码 → 中文,对齐旧主账 businessLineLabel。 */
|
||||
function businessLineLabel(v: any) {
|
||||
const raw = String(v ?? "").trim();
|
||||
const upper = raw.toUpperCase();
|
||||
if (upper === "AIR") return "空运";
|
||||
if (upper === "SEA") return "海运";
|
||||
if (upper === "LAND") return "陆运";
|
||||
if (upper === "MULTI") return "多段联运";
|
||||
return raw;
|
||||
}
|
||||
|
||||
/** FP:AIR|… 管道原文禁止当询价需求展示。 */
|
||||
function looksLikeRawFp(v: any) {
|
||||
const raw = String(v ?? "").trim();
|
||||
return raw.startsWith("FP:") || (raw.includes("|") && raw.includes(":"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 询价需求摘要:对齐旧后台 requirementSummaryLines。
|
||||
* 首行 = 业务线 · 路线;后续 = 货量 / 品名。结构化字段优先,不信 demandDetail 管道串。
|
||||
*/
|
||||
function requirementSummaryLines(t: any): string[] {
|
||||
const businessLine = String(t?.businessLine ?? "").trim().toUpperCase();
|
||||
const lineLabel = String(t?.bizTypeLabel ?? "").trim() || businessLineLabel(t?.businessLine);
|
||||
const origin = String(t?.origin ?? "").trim();
|
||||
const destination = String(t?.destination ?? "").trim();
|
||||
const route =
|
||||
origin || destination ? `${origin || "-"} → ${destination || "-"}` : "";
|
||||
const heading = [lineLabel, route].filter(Boolean).join(" · ");
|
||||
const lines: string[] = [];
|
||||
if (heading) lines.push(heading);
|
||||
|
||||
const isAir = businessLine === "AIR" || lineLabel.includes("空运");
|
||||
const isSea = businessLine === "SEA" || lineLabel.includes("海运");
|
||||
const isLand = businessLine === "LAND" || lineLabel.includes("陆运");
|
||||
const quantities: string[] = [];
|
||||
const pieces = t?.pieces;
|
||||
const weight = t?.weightKg;
|
||||
const volume = t?.volumeCbm;
|
||||
const plain = (v: any) => {
|
||||
if (v == null || v === "") return "";
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) ? String(n) : String(v).trim();
|
||||
};
|
||||
if (isAir) {
|
||||
if (pieces != null && pieces !== "") quantities.push(`件数 ${plain(pieces)}`);
|
||||
if (weight != null && weight !== "") quantities.push(`毛重 ${plain(weight)}kg`);
|
||||
if (volume != null && volume !== "") quantities.push(`体积 ${plain(volume)}CBM`);
|
||||
} else if (isSea) {
|
||||
if (pieces != null && pieces !== "") quantities.push(`件数 ${plain(pieces)}`);
|
||||
if (weight != null && weight !== "") quantities.push(`重量 ${plain(weight)}kg`);
|
||||
if (volume != null && volume !== "") quantities.push(`体积 ${plain(volume)}CBM`);
|
||||
} else if (isLand) {
|
||||
if (volume != null && volume !== "") quantities.push(`体积 ${plain(volume)}CBM`);
|
||||
if (weight != null && weight !== "") quantities.push(`重量 ${plain(weight)}kg`);
|
||||
if (pieces != null && pieces !== "") quantities.push(`件数 ${plain(pieces)}`);
|
||||
} else {
|
||||
if (pieces != null && pieces !== "") quantities.push(`件数 ${plain(pieces)}`);
|
||||
if (weight != null && weight !== "") quantities.push(`重量 ${plain(weight)}kg`);
|
||||
if (volume != null && volume !== "") quantities.push(`体积 ${plain(volume)}CBM`);
|
||||
}
|
||||
if (quantities.length) lines.push(quantities.join(" · "));
|
||||
|
||||
const cargo: string[] = [];
|
||||
const goods = String(t?.goodsName ?? "").trim();
|
||||
if (goods) cargo.push(`${isLand ? "货物品名" : "品名"} ${goods}`);
|
||||
if (isAir) {
|
||||
const customer = String(t?.customerName ?? "").trim();
|
||||
if (customer) cargo.push(`客户 ${customer}`);
|
||||
}
|
||||
if (cargo.length) lines.push(cargo.join(" · "));
|
||||
|
||||
// 后端已 enrich 时直接用;仅当结构化拼不出时才回退已有标题(仍过滤 FP 原文)
|
||||
if (!lines.length) {
|
||||
const title = String(t?.demandTitle ?? "").trim();
|
||||
const detail = String(t?.demandDetail ?? "").trim();
|
||||
if (title && !looksLikeRawFp(title)) lines.push(title);
|
||||
if (detail && !looksLikeRawFp(detail)) {
|
||||
detail.split(/\r?\n/).map((x) => x.trim()).filter(Boolean).forEach((x) => lines.push(x));
|
||||
}
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
function mapTicket(t: any, quotes: any[] = [], attachments: any[] = [], timeline: any[] = []): TicketRow {
|
||||
const attachRows: TicketAttach[] = (attachments || [])
|
||||
.map((a) => ({
|
||||
@@ -93,15 +208,15 @@ function mapTicket(t: any, quotes: any[] = [], attachments: any[] = [], timeline
|
||||
.filter((a) => a.name);
|
||||
const origin = dash(t.origin);
|
||||
const destination = dash(t.destination);
|
||||
const route =
|
||||
origin !== "-" || destination !== "-"
|
||||
? `${origin === "-" ? "" : origin} → ${destination === "-" ? "" : destination}`.trim()
|
||||
: "";
|
||||
const reqLines = requirementSummaryLines(t);
|
||||
const salesName = String(t.salesName ?? "").trim();
|
||||
// 发起人必须显示名称;企微 userid 仅作最后兜底(主账 enrich 后应已是中文名)
|
||||
const initiator = salesName || t.salesWecomId || "—";
|
||||
return {
|
||||
id: t.workOrderNo || t.id || "",
|
||||
initiator: t.salesName || t.salesWecomId || "—",
|
||||
demandTitle: t.demandTitle || route || "—",
|
||||
demandDetail: t.demandDetail || "",
|
||||
initiator,
|
||||
demandTitle: reqLines[0] || "—",
|
||||
demandDetail: reqLines.slice(1).join("\n"),
|
||||
quotes: (quotes || []).map((q) => ({
|
||||
source: q.sourceLabel || q.source || "报价",
|
||||
price: q.priceText || "-",
|
||||
@@ -111,7 +226,7 @@ function mapTicket(t: any, quotes: any[] = [], attachments: any[] = [], timeline
|
||||
attachRows,
|
||||
timeline: (timeline || []).map((x) => ({
|
||||
status: dash(x.toStatus || x.status),
|
||||
remark: dash(x.remark || x.action),
|
||||
remark: timelineNote(x),
|
||||
time: fmtTime(x.createTime),
|
||||
})),
|
||||
status: t.status || "询价中",
|
||||
@@ -142,6 +257,14 @@ function routeText(row: TicketRow) {
|
||||
return row.demandTitle || "-";
|
||||
}
|
||||
|
||||
/** 列表询价需求第二行起:与旧后台 summary-cell 多行展示一致。 */
|
||||
function demandDetailLines(row: TicketRow) {
|
||||
return String(row?.demandDetail || "")
|
||||
.split(/\r?\n/)
|
||||
.map((x) => x.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
async function loadList() {
|
||||
loading.value = true;
|
||||
try {
|
||||
@@ -350,17 +473,13 @@ function refresh() {
|
||||
<span style="font-size: 12px; font-weight: 700">{{ row.initiator }}</span>
|
||||
</td>
|
||||
<td class="align-top">
|
||||
<div style="font-weight: 800">{{ row.demandTitle }}</div>
|
||||
<div
|
||||
style="
|
||||
font-size: 10px;
|
||||
color: #64748b;
|
||||
margin-top: 4px;
|
||||
white-space: pre-line;
|
||||
line-height: 1.5;
|
||||
"
|
||||
>
|
||||
{{ row.demandDetail }}
|
||||
<div class="summary-cell">
|
||||
<strong>{{ row.demandTitle }}</strong>
|
||||
<span
|
||||
v-for="(line, idx) in demandDetailLines(row)"
|
||||
:key="row.id + '-demand-' + idx"
|
||||
>{{ line }}</span
|
||||
>
|
||||
</div>
|
||||
</td>
|
||||
<td class="align-top">
|
||||
@@ -491,8 +610,9 @@ function refresh() {
|
||||
<ProtoIcon name="route" :size="14" />询价需求
|
||||
</div>
|
||||
<div class="ticket-detail-demand">
|
||||
<div style="font-weight: 800; color: #0f172a">
|
||||
{{ dash(current.bizType) }}:{{ routeText(current) }}
|
||||
<div class="summary-cell">
|
||||
<strong>{{ current.demandTitle }}</strong>
|
||||
<span v-for="(line, idx) in demandDetailLines(current)" :key="'detail-demand-' + idx">{{ line }}</span>
|
||||
</div>
|
||||
<div class="ticket-detail-grid" style="margin-top: 8px">
|
||||
<div class="ticket-detail-item"><label>业务类型</label><span>{{ dash(current.bizType) }}</span></div>
|
||||
@@ -503,7 +623,6 @@ function refresh() {
|
||||
<div class="ticket-detail-item"><label>品名</label><span>{{ current.goodsName }}</span></div>
|
||||
<div class="ticket-detail-item"><label>客户</label><span>{{ current.customerName }}</span></div>
|
||||
</div>
|
||||
<div v-if="current.demandDetail" class="ticket-detail-note">{{ current.demandDetail }}</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -570,3 +689,27 @@ function refresh() {
|
||||
</div>
|
||||
</AdminModal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 询价需求列:对齐旧后台 summary-cell(首行加粗,后续灰色小字) */
|
||||
.summary-cell {
|
||||
min-width: 230px;
|
||||
white-space: normal;
|
||||
word-break: break-word;
|
||||
line-height: 1.55;
|
||||
}
|
||||
.summary-cell strong,
|
||||
.summary-cell span {
|
||||
display: block;
|
||||
}
|
||||
.summary-cell strong {
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
color: #0f172a;
|
||||
}
|
||||
.summary-cell span {
|
||||
margin-top: 3px;
|
||||
color: #708090;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user