Compare commits

..
Author SHA1 Message Date
jillion886 cb3bf5238b 合并远程主仓:纳入工单日期筛选、服务端分页与分析看板,保留询价出单。 2026-09-15 09:02:06 +08:00
linmianxunandCursor 11ec728cf4 后台列表统一分页组件,工单/人员/账号改为服务端分页。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-14 22:19:58 +08:00
linmianxunandCursor 9d4889c482 分析看板补齐成本趋势与排名:主账聚合近7日线路均价,后台按真实数据绘图。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-14 21:49:48 +08:00
linmianxunandCursor 07c988e61f 工单创建时间改用 Ant RangePicker,一次弹层选起止并与筛选同步。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-14 21:27:26 +08:00
jillion886 1847504807 Merge remote-tracking branch 'origin/main' into feat/tms-quote-staff-sync 2026-09-14 21:24:55 +08:00
linmianxunandCursor b1eba3facc 工单筛选日期控件对齐原型:可见文案、点选即弹日历。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-14 21:24:20 +08:00
linmianxunandCursor 38a25a4b40 人员所属部门下拉对齐旧项目:主账提供 /departments 短名全量,后台可搜可选。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-14 21:13:52 +08:00
jillion886andCursor 060f865a1e 合并远程主账与后台更新:保留查价出单,并纳入企微通讯录同步与工单展示。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-14 20:37:14 +08:00
linmianxunandCursor 624ec1b7c4 主账支持企微通讯录同步人员,并完善工单展示与后台人员/工单页联调能力。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-14 20:26:36 +08:00
21 changed files with 2200 additions and 183 deletions
@@ -11,21 +11,30 @@ 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.ArrayList;
import java.util.Date;
import java.util.HashMap;
import java.util.LinkedHashSet;
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 +45,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 +134,73 @@ 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);
}
/**
* 员工「所属部门」下拉(对齐旧项目 admin-rel26)。
* <p>
* 旧前端:{@code GET /inquiry/staffRole/departments} → {@code string[]},
* 再 {@code options: names.map(n => ({label:n,value:n}))},showSearch 可滚动全量。
* 本接口同源:企微 {@code department/list} 短名全量;根公司(id=1)不进下拉。
* 浏览器禁止直连企微。
*/
@ApiOperation("员工部门名列表(对齐旧项目 /departments)")
@RequiresPermissions("inquiry:staff:list")
@GetMapping("/departments")
public Result<List<String>> departments() {
List<WeComDepartment> all = weComAddressBookClient.listDepartments();
List<String> names = new ArrayList<>();
LinkedHashSet<String> seen = new LinkedHashSet<>();
for (WeComDepartment d : all) {
if (d == null || StringUtils.isBlank(d.getName())) {
continue;
}
// 企微根部门(id=1 / parentid=0)是公司全称,员工下拉不展示
if (d.getId() == 1 || d.getParentId() <= 0) {
continue;
}
String name = d.getName().trim();
if (seen.add(name)) {
names.add(name);
}
}
return Result.OK(names);
}
/**
* 企微部门结构列表(含 id/path,供排查)。员工下拉请用 {@link #departments()}。
*/
@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 不能为空");
@@ -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;
}
@@ -8,7 +8,8 @@ import java.util.Map;
public interface IInquiryDashboardService {
/**
* 返回看板指标:metrics 列表 + trend + rankings。
* 返回看板指标:metrics + trend + rankings + costDates/costRoutes。
* rankings 与工单列表同套发起人解析;costRoutes 为近 7 日线路均价(主账工单投影)。
*
* @param range today|7d|30d,默认 today
*/
@@ -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);
}
@@ -1,12 +1,14 @@
package org.jeecg.modules.inquiry.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import org.apache.commons.lang3.StringUtils;
import org.jeecg.modules.inquiry.domain.TicketStatuses;
import org.jeecg.modules.inquiry.entity.InquiryStaffRole;
import org.jeecg.modules.inquiry.entity.InquiryTicket;
import org.jeecg.modules.inquiry.mapper.InquiryStaffRoleMapper;
import org.jeecg.modules.inquiry.mapper.InquiryTicketMapper;
import org.jeecg.modules.inquiry.service.IInquiryDashboardService;
import org.jeecg.modules.inquiry.support.InquiryTicketDisplaySupport;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@@ -18,24 +20,41 @@ import java.time.LocalTime;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.Date;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.stream.Collectors;
/**
* 看板实时聚合实现(无快照依赖)。
* <p>
* 计算口径对齐后台原型:询价总数、待处理、TMS 命中率、异常完成率、手工报价占比、
* 计算口径:询价总数、待处理、TMS 命中率、异常完成率、手工报价占比、
* 已成交、成交率、反馈率;内部协同时长本期无协同表时返回「-」。
* 人员排名与工单列表同一套发起人解析(企微 id → 员工姓名),禁止只认库内空 sales_name。
* 成本趋势:近 7 日工单按线路聚合均价(成本价优先,缺省用销售价),非浏览器直连 TMS。
* 线程:只读查询,无写锁。
*/
@Service
public class InquiryDashboardServiceImpl implements IInquiryDashboardService {
private static final ZoneId ZONE = ZoneId.of("Asia/Shanghai");
private static final DateTimeFormatter DAY_FMT = DateTimeFormatter.ofPattern("MM-dd");
private static final int COST_ROUTE_LIMIT = 16;
private static final String[] COST_COLORS = {
"#2563eb", "#3b82f6", "#0891b2", "#0e7490", "#6366f1", "#8b5cf6",
"#a855f7", "#c026d3", "#db2777", "#e11d48", "#dc2626", "#ea580c",
"#d97706", "#65a30d", "#0d9488", "#059669", "#0284c7"
};
/** 单日金额累加(成本趋势用)。 */
private static final class DaySum {
private BigDecimal sum = BigDecimal.ZERO;
private int count;
}
@Autowired
private InquiryTicketMapper ticketMapper;
@@ -91,59 +110,306 @@ public class InquiryDashboardServiceImpl implements IInquiryDashboardService {
metricCards.add(card("accepted", "已成交工单", "状态=已成交的工单数量", String.valueOf(deal), false));
metricCards.add(card("dealRate", "报价成交率", "已成交数 ÷ 已报价相关数 × 100%", pct(deal, Math.max(quoted, 1)), false));
long feedbackNumer = deal + lost;
// 反馈率口径:已成交+未成交(相对周期工单)
metricCards.add(card("feedback", "报价反馈率", "(已成交 + 未成交)÷ 工单数 × 100%", pct(feedbackNumer, total), false));
metricCards.add(card("avgResp", "内部协同平均响应时长", "本期无协同明细表,暂无数据", "-", true));
// 近 7 日趋势(不受 range 限制,便于折线)
List<Map<String, Object>> trend = new ArrayList<>();
DateTimeFormatter fmt = DateTimeFormatter.ofPattern("MM-dd");
// 近 7 日趋势 + 成本:一次拉周内工单,避免 7 次 count
Date weekFrom = Date.from(today.minusDays(6).atStartOfDay(ZONE).toInstant());
List<InquiryTicket> weekTickets = ticketMapper.selectList(new LambdaQueryWrapper<InquiryTicket>()
.ge(InquiryTicket::getCreateTime, weekFrom)
.le(InquiryTicket::getCreateTime, to));
List<String> trendDates = new ArrayList<>(7);
List<Map<String, Object>> trend = new ArrayList<>(7);
Map<LocalDate, Integer> dayIndex = new HashMap<>();
for (int i = 6; i >= 0; i--) {
LocalDate d = today.minusDays(i);
Date dFrom = Date.from(d.atStartOfDay(ZONE).toInstant());
Date dTo = Date.from(LocalDateTime.of(d, LocalTime.MAX).atZone(ZONE).toInstant());
long cnt = ticketMapper.selectCount(new LambdaQueryWrapper<InquiryTicket>()
.ge(InquiryTicket::getCreateTime, dFrom)
.le(InquiryTicket::getCreateTime, dTo));
dayIndex.put(d, trendDates.size());
String label = d.format(DAY_FMT);
trendDates.add(label);
Map<String, Object> p = new HashMap<>();
p.put("date", d.format(fmt));
p.put("value", cnt);
p.put("date", label);
p.put("value", 0L);
trend.add(p);
}
for (InquiryTicket t : weekTickets) {
LocalDate d = toLocalDate(t.getCreateTime());
Integer idx = d == null ? null : dayIndex.get(d);
if (idx == null) {
continue;
}
Map<String, Object> p = trend.get(idx);
p.put("value", ((Number) p.get("value")).longValue() + 1L);
}
// 人员排名:按销售名聚合询价量
Map<String, Long> bySales = tickets.stream()
.filter(t -> t.getSalesName() != null && !t.getSalesName().isEmpty())
.collect(Collectors.groupingBy(InquiryTicket::getSalesName, Collectors.counting()));
Map<String, InquiryStaffRole> staffByName = staffRoleMapper.selectList(null).stream()
.collect(Collectors.toMap(InquiryStaffRole::getName, s -> s, (a, b) -> a));
List<Map<String, Object>> rankings = bySales.entrySet().stream()
.sorted(Map.Entry.<String, Long>comparingByValue().reversed())
.limit(20)
.map(e -> {
Map<String, Object> row = new LinkedHashMap<>();
row.put("name", e.getKey());
InquiryStaffRole staff = staffByName.get(e.getKey());
if (staff != null) {
row.put("role", mapRankRole(staff.getRoleCode()));
row.put("dept", staff.getDepartName());
} else {
row.put("role", "sales");
row.put("dept", "-");
}
row.put("volumeNum", e.getValue());
return row;
})
.collect(Collectors.toList());
List<InquiryStaffRole> staffRows = staffRoleMapper.selectList(null);
Map<String, InquiryStaffRole> staffByWecom = new HashMap<>();
Map<String, InquiryStaffRole> staffByName = new HashMap<>();
for (InquiryStaffRole s : staffRows) {
if (s == null) {
continue;
}
if (StringUtils.isNotBlank(s.getWecomId())) {
staffByWecom.put(s.getWecomId().trim(), s);
}
if (StringUtils.isNotBlank(s.getName())) {
staffByName.putIfAbsent(s.getName().trim(), s);
}
}
List<Map<String, Object>> rankings = buildRankings(tickets, staffByWecom, staffByName);
List<Map<String, Object>> costRoutes = buildCostRoutes(weekTickets, dayIndex, trendDates);
Map<String, Object> result = new LinkedHashMap<>();
result.put("range", range == null ? "today" : range);
result.put("metrics", metricCards);
result.put("trend", trend);
result.put("rankings", rankings);
result.put("costDates", trendDates);
result.put("costRoutes", costRoutes);
return result;
}
/**
* 人员排名:与工单列表 enrichDisplayFields 同口径。
* 聚合键优先 salesWecomId(同人合并),展示名走 resolveSalesDisplayName。
*/
private static List<Map<String, Object>> buildRankings(List<InquiryTicket> tickets,
Map<String, InquiryStaffRole> staffByWecom,
Map<String, InquiryStaffRole> staffByName) {
class Acc {
String name;
InquiryStaffRole staff;
long volume;
}
Map<String, Acc> byKey = new LinkedHashMap<>();
for (InquiryTicket t : tickets) {
if (t == null) {
continue;
}
String wecom = StringUtils.trimToEmpty(t.getSalesWecomId());
InquiryStaffRole staff = StringUtils.isNotBlank(wecom) ? staffByWecom.get(wecom) : null;
String staffName = staff != null ? staff.getName() : null;
String display = InquiryTicketDisplaySupport.resolveSalesDisplayName(
t.getSalesName(), t.getSalesWecomId(), staffName);
if (StringUtils.isBlank(display)) {
continue;
}
// 仅企微 userid、又无员工绑定时不当人名进榜(与列表「应显示中文名」一致)
if (staff == null && InquiryTicketDisplaySupport.looksLikeWecomUserId(display)) {
continue;
}
if (staff == null) {
staff = staffByName.get(display.trim());
}
String key = StringUtils.isNotBlank(wecom) ? "w:" + wecom : "n:" + display.trim();
Acc acc = byKey.get(key);
if (acc == null) {
acc = new Acc();
acc.name = display.trim();
acc.staff = staff;
acc.volume = 0L;
byKey.put(key, acc);
}
acc.volume++;
if (acc.staff == null && staff != null) {
acc.staff = staff;
}
// 有员工中文名时覆盖展示名,避免残留 userid
if (acc.staff != null && StringUtils.isNotBlank(acc.staff.getName())) {
acc.name = acc.staff.getName().trim();
}
}
return byKey.values().stream()
.sorted(Comparator.comparingLong((Acc a) -> a.volume).reversed()
.thenComparing(a -> a.name, Comparator.nullsLast(String::compareTo)))
.limit(20)
.map(a -> {
Map<String, Object> row = new LinkedHashMap<>();
row.put("name", a.name);
if (a.staff != null) {
row.put("role", mapRankRole(a.staff.getRoleCode()));
row.put("dept", StringUtils.defaultIfBlank(a.staff.getDepartName(), "-"));
} else {
row.put("role", "sales");
row.put("dept", "-");
}
row.put("volumeNum", a.volume);
return row;
})
.collect(Collectors.toList());
}
/**
* 成本趋势:近 7 日按线路日均价。
* 金额:cost_price 可解析则用;否则回退 sale_price(避免全空折线)。
* 线路:起运地-目的地;缺省业务线标签。
*/
private static List<Map<String, Object>> buildCostRoutes(List<InquiryTicket> weekTickets,
Map<LocalDate, Integer> dayIndex,
List<String> trendDates) {
int days = trendDates.size();
class RouteAcc {
String label;
DaySum[] daysArr = new DaySum[days];
int samples;
RouteAcc(String label) {
this.label = label;
for (int i = 0; i < days; i++) {
daysArr[i] = new DaySum();
}
}
}
Map<String, RouteAcc> byRoute = new LinkedHashMap<>();
DaySum[] allLine = new DaySum[days];
for (int i = 0; i < days; i++) {
allLine[i] = new DaySum();
}
int allSamples = 0;
for (InquiryTicket t : weekTickets) {
if (t == null) {
continue;
}
BigDecimal amount = parseMoney(t.getCostPrice());
if (amount == null) {
amount = parseMoney(t.getSalePrice());
}
if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) {
continue;
}
LocalDate d = toLocalDate(t.getCreateTime());
Integer idx = d == null ? null : dayIndex.get(d);
if (idx == null) {
continue;
}
String label = routeLabel(t);
String id = routeId(label);
RouteAcc acc = byRoute.get(id);
if (acc == null) {
acc = new RouteAcc(label);
byRoute.put(id, acc);
}
DaySum ds = acc.daysArr[idx];
ds.sum = ds.sum.add(amount);
ds.count++;
acc.samples++;
allLine[idx].sum = allLine[idx].sum.add(amount);
allLine[idx].count++;
allSamples++;
}
List<Map.Entry<String, RouteAcc>> top = byRoute.entrySet().stream()
.sorted(Comparator.comparingInt((Map.Entry<String, RouteAcc> e) -> e.getValue().samples).reversed())
.limit(COST_ROUTE_LIMIT)
.collect(Collectors.toList());
List<Map<String, Object>> routes = new ArrayList<>();
if (allSamples > 0) {
routes.add(toCostRouteRow("quanxian", "全线", COST_COLORS[0], allLine, days));
}
int colorIdx = 1;
for (Map.Entry<String, RouteAcc> e : top) {
if ("quanxian".equals(e.getKey()) || "全线".equals(e.getValue().label)) {
continue;
}
String color = COST_COLORS[colorIdx % COST_COLORS.length];
colorIdx++;
routes.add(toCostRouteRow(e.getKey(), e.getValue().label, color, e.getValue().daysArr, days));
}
return routes;
}
private static Map<String, Object> toCostRouteRow(String id, String label, String color, DaySum[] daysArr, int days) {
List<Object> values = new ArrayList<>(days);
for (int i = 0; i < days; i++) {
DaySum ds = daysArr[i];
if (ds == null || ds.count <= 0) {
values.add(null);
} else {
values.add(ds.sum.divide(BigDecimal.valueOf(ds.count), 2, RoundingMode.HALF_UP));
}
}
Map<String, Object> row = new LinkedHashMap<>();
row.put("id", id);
row.put("label", label);
row.put("color", color);
row.put("values", values);
return row;
}
/** 金额串:「CNY 1,234.5」「1234.5」→ BigDecimal;「-」/空 → null。 */
static BigDecimal parseMoney(String raw) {
if (raw == null) {
return null;
}
String s = raw.trim();
if (s.isEmpty() || "-".equals(s) || "—".equals(s)) {
return null;
}
s = s.replace(",", "");
StringBuilder num = new StringBuilder();
boolean dot = false;
for (int i = 0; i < s.length(); i++) {
char c = s.charAt(i);
if (c >= '0' && c <= '9') {
num.append(c);
} else if (c == '.' && !dot) {
num.append(c);
dot = true;
} else if (c == '-' && num.length() == 0) {
num.append(c);
}
}
if (num.length() == 0 || "-".contentEquals(num) || ".".contentEquals(num)) {
return null;
}
try {
return new BigDecimal(num.toString());
} catch (NumberFormatException ex) {
return null;
}
}
static String routeLabel(InquiryTicket t) {
String o = StringUtils.trimToEmpty(t.getOrigin());
String d = StringUtils.trimToEmpty(t.getDestination());
if (StringUtils.isNotBlank(o) && StringUtils.isNotBlank(d)) {
return o + "-" + d;
}
if (StringUtils.isNotBlank(t.getBizTypeLabel())) {
return t.getBizTypeLabel().trim() + "全线";
}
String line = StringUtils.trimToEmpty(t.getBusinessLine()).toUpperCase(Locale.ROOT);
if ("AIR".equals(line)) {
return "空运全线";
}
if ("SEA".equals(line)) {
return "海运全线";
}
if ("LAND".equals(line)) {
return "陆运全线";
}
return "未分线路";
}
static String routeId(String label) {
String raw = StringUtils.trimToEmpty(label).toLowerCase(Locale.ROOT);
String id = raw.replaceAll("[^a-z0-9\\u4e00-\\u9fff]+", "-");
if (id.isEmpty()) {
return "route";
}
return id.length() > 48 ? id.substring(0, 48) : id;
}
private static LocalDate toLocalDate(Date time) {
if (time == null) {
return null;
}
return time.toInstant().atZone(ZONE).toLocalDate();
}
private static boolean hasExc(String v) {
return v != null && !v.trim().isEmpty() && !"-".equals(v.trim());
}
@@ -166,9 +432,13 @@ public class InquiryDashboardServiceImpl implements IInquiryDashboardService {
return m;
}
/** 员工 roleCode → 看板筛选项 sales/route/product。 */
private static String mapRankRole(String roleCode) {
if ("air".equals(roleCode) || "sea".equals(roleCode) || "land".equals(roleCode)) {
return "air".equals(roleCode) ? "route" : "product";
if ("air".equals(roleCode)) {
return "route";
}
if ("sea".equals(roleCode) || "land".equals(roleCode)) {
return "product";
}
return "sales";
}
@@ -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;
}
}
}
@@ -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) {
@@ -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);
}
}
@@ -0,0 +1,288 @@
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());
}
/**
* 拉取企微 {@code department/list} 全量扁平列表(短名 + 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));
}
// 保持企微 department/list 原序(LinkedHashMap 插入序 = 接口返回序),
// 不要按 path 重排,也不要截层级;员工下拉与旧项目一样用这份全量短名。
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;
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -0,0 +1,33 @@
package org.jeecg.modules.inquiry.service.impl;
import org.jeecg.modules.inquiry.entity.InquiryTicket;
import org.junit.Assert;
import org.junit.Test;
import java.math.BigDecimal;
/**
* 看板聚合辅助:金额解析与线路标签(不启 Spring)。
*/
public class InquiryDashboardServiceImplTest {
@Test
public void parseMoneyAcceptsCurrencyPrefix() {
Assert.assertEquals(new BigDecimal("14659.60"), InquiryDashboardServiceImpl.parseMoney("CNY 14659.60"));
Assert.assertEquals(new BigDecimal("1234.5"), InquiryDashboardServiceImpl.parseMoney("1,234.5"));
Assert.assertNull(InquiryDashboardServiceImpl.parseMoney("-"));
Assert.assertNull(InquiryDashboardServiceImpl.parseMoney(""));
}
@Test
public void routeLabelPrefersOdPair() {
InquiryTicket t = new InquiryTicket();
t.setOrigin("阿特伯");
t.setDestination("广州");
Assert.assertEquals("阿特伯-广州", InquiryDashboardServiceImpl.routeLabel(t));
InquiryTicket air = new InquiryTicket();
air.setBusinessLine("AIR");
Assert.assertEquals("空运全线", InquiryDashboardServiceImpl.routeLabel(air));
}
}
@@ -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"));
}
}
@@ -18,9 +18,13 @@ import {
} from "/@/views/inquiry/account/account.api";
import ProtoIcon from "/@/views/inquiry/components/ProtoIcon.vue";
import AdminModal from "/@/views/inquiry/components/AdminModal.vue";
import AdminPagination from "/@/views/inquiry/components/AdminPagination.vue";
const rows = ref<AccountRow[]>([]);
const loading = ref(false);
const total = ref(0);
const pageNo = ref(1);
const pageSize = ref(10);
const roleOptions = ref<{ value: string; label: string; tag: string }[]>([]);
const filters = reactive({ keyword: "", status: "" as "" | "active" | "disabled" });
const modalOpen = ref(false);
@@ -35,15 +39,6 @@ const form = reactive({
password: "",
});
const filtered = computed(() =>
rows.value.filter((r) => {
const kw = filters.keyword.trim().toLowerCase();
if (kw && !`${r.username}${r.name}${r.wecomUserId}${r.phoneMasked}`.toLowerCase().includes(kw)) return false;
if (filters.status && r.status !== filters.status) return false;
return true;
}),
);
const modalTitle = computed(() => (editing.value ? "编辑账号" : "新建账号"));
const modalSub = computed(() =>
editing.value ? `正在编辑:${editing.value.username}` : "创建账号并绑定角色",
@@ -67,8 +62,28 @@ async function loadList() {
loading.value = true;
try {
if (!roleOptions.value.length) await loadRoles();
const res: any = await listUsers({ pageNo: 1, pageSize: 200 });
const records = res?.records || res?.result?.records || [];
const kw = filters.keyword.trim();
// Jeecg QueryGenerator:字段值包 * 走 LIKE。多字段 AND 过严,按关键字形态选一个字段。
const params: Record<string, any> = {
pageNo: pageNo.value,
pageSize: pageSize.value,
};
if (kw) {
if (/[\u4e00-\u9fff]/.test(kw)) {
params.realname = `*${kw}*`;
} else if (/^\d{5,}$/.test(kw)) {
params.phone = `*${kw}*`;
} else {
params.username = `*${kw}*`;
}
}
if (filters.status === "active") params.status = 1;
if (filters.status === "disabled") params.status = 2;
const res: any = await listUsers(params);
const page = res?.records ? res : res?.result || {};
const records = page.records || [];
total.value = Number(page.total || records.length || 0);
const mapped: AccountRow[] = [];
for (const u of records as any[]) {
let roleId = "";
@@ -89,6 +104,11 @@ async function loadList() {
mapped.push(mapUserToAccount(u, roleId, roleLabel, roleTag));
}
rows.value = mapped;
const maxPage = Math.max(1, Math.ceil(total.value / pageSize.value) || 1);
if (pageNo.value > maxPage) {
pageNo.value = maxPage;
await loadList();
}
} catch (e: any) {
message.error(e?.message || "加载账号失败");
} finally {
@@ -96,6 +116,20 @@ async function loadList() {
}
}
let accountSearchTimer: ReturnType<typeof setTimeout> | null = null;
watch(
() => [filters.keyword, filters.status],
() => {
pageNo.value = 1;
if (accountSearchTimer) clearTimeout(accountSearchTimer);
accountSearchTimer = setTimeout(() => loadList(), 250);
},
);
function onPageChange() {
loadList();
}
onMounted(() => loadList());
function openCreate() {
@@ -225,13 +259,6 @@ function removeAccount(row: AccountRow) {
},
});
}
watch(
() => filters.keyword,
() => {
/* 本地过滤即可,账号量通常很小 */
},
);
</script>
<template>
@@ -250,7 +277,7 @@ watch(
<div class="toolbar">
<div class="search">
<ProtoIcon class="search-icon" name="search" :size="16" />
<input v-model="filters.keyword" placeholder="搜索账号、姓名、企微ID、手机号..." />
<input v-model="filters.keyword" placeholder="搜索账号 / 姓名 / 手机号..." />
</div>
<select v-model="filters.status">
<option value="">状态</option>
@@ -276,10 +303,10 @@ watch(
<tr v-if="loading">
<td colspan="7" style="text-align: center; color: #94a3b8">加载中…</td>
</tr>
<tr v-else-if="!filtered.length">
<tr v-else-if="!rows.length">
<td colspan="7" style="text-align: center; color: #94a3b8">暂无账号</td>
</tr>
<tr v-for="row in filtered" :key="row.id">
<tr v-for="row in rows" :key="row.id">
<td class="account-username">{{ row.username }}</td>
<td>{{ row.name }}</td>
<td>
@@ -312,14 +339,14 @@ watch(
</tbody>
</table>
</div>
<div class="pagination">
<span>
共 <b style="color: #0f172a">{{ filtered.length }}</b> 个账号
</span>
<div class="pages">
<button class="is-active" type="button">1</button>
</div>
</div>
<AdminPagination
v-model:page-no="pageNo"
v-model:page-size="pageSize"
:total="total"
unit="个账号"
:disabled="loading"
@change="onPageChange"
/>
</div>
<AdminModal
@@ -0,0 +1,172 @@
<script setup lang="ts">
/**
* 询价后台列表分页条:总数 + 每页条数 + 页码。
* 样式对齐 admin-fidelity.css 的 .pagination;翻页只发事件,不请求接口。
*/
import { computed } from "vue";
const props = withDefaults(
defineProps<{
total: number;
pageNo: number;
pageSize: number;
/** 文案:共 N {unit} */
unit?: string;
pageSizeOptions?: number[];
disabled?: boolean;
}>(),
{
unit: "条",
pageSizeOptions: () => [10, 20, 50, 100],
disabled: false,
},
);
const emit = defineEmits<{
(e: "update:pageNo", v: number): void;
(e: "update:pageSize", v: number): void;
(e: "change"): void;
}>();
const totalPages = computed(() => Math.max(1, Math.ceil(Math.max(0, props.total) / Math.max(1, props.pageSize))));
/**
* 页码按钮:两端保留,中间省略。
* 例:1 … 4 5 6 … 20
*/
const pageItems = computed(() => {
const total = totalPages.value;
const cur = Math.min(Math.max(1, props.pageNo), total);
if (total <= 7) {
return Array.from({ length: total }, (_, i) => i + 1) as Array<number | "ellipsis">;
}
const set = new Set<number>();
set.add(1);
set.add(total);
for (let p = cur - 1; p <= cur + 1; p++) {
if (p >= 1 && p <= total) set.add(p);
}
if (cur <= 3) {
set.add(2);
set.add(3);
set.add(4);
}
if (cur >= total - 2) {
set.add(total - 1);
set.add(total - 2);
set.add(total - 3);
}
const sorted = [...set].sort((a, b) => a - b);
const out: Array<number | "ellipsis"> = [];
let prev = 0;
for (const p of sorted) {
if (prev && p - prev > 1) out.push("ellipsis");
out.push(p);
prev = p;
}
return out;
});
function go(page: number) {
if (props.disabled) return;
const next = Math.min(Math.max(1, page), totalPages.value);
if (next === props.pageNo) return;
emit("update:pageNo", next);
emit("change");
}
function onSizeChange(e: Event) {
if (props.disabled) return;
const v = Number((e.target as HTMLSelectElement).value);
if (!v || v === props.pageSize) return;
emit("update:pageSize", v);
emit("update:pageNo", 1);
emit("change");
}
</script>
<template>
<div class="pagination admin-pagination">
<span class="admin-pagination__total">
共 <b>{{ total }}</b> {{ unit }}
<span v-if="total > 0" class="admin-pagination__range">
(第 {{ pageNo }} / {{ totalPages }} 页)
</span>
</span>
<div class="admin-pagination__right">
<label class="admin-pagination__size">
每页
<select :value="pageSize" :disabled="disabled" @change="onSizeChange">
<option v-for="n in pageSizeOptions" :key="n" :value="n">{{ n }}</option>
</select>
</label>
<div class="pages">
<button type="button" :disabled="disabled || pageNo <= 1" @click="go(pageNo - 1)">上一页</button>
<template v-for="(item, idx) in pageItems" :key="idx + '-' + item">
<span v-if="item === 'ellipsis'" class="admin-pagination__ellipsis">…</span>
<button
v-else
type="button"
:class="{ 'is-active': item === pageNo }"
:disabled="disabled"
@click="go(item)"
>
{{ item }}
</button>
</template>
<button type="button" :disabled="disabled || pageNo >= totalPages" @click="go(pageNo + 1)">下一页</button>
</div>
</div>
</div>
</template>
<style scoped>
.admin-pagination__right {
display: inline-flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
justify-content: flex-end;
}
.admin-pagination__size {
display: inline-flex;
align-items: center;
gap: 6px;
color: #475467;
font-size: 11px;
font-weight: 600;
}
.admin-pagination__size select {
height: 28px;
border: 1px solid var(--line, #e4e7ec);
border-radius: 6px;
padding: 0 8px;
background: #fff;
font-size: 11px;
font-weight: 700;
color: #344054;
cursor: pointer;
}
.admin-pagination__range {
color: #98a2b3;
font-weight: 600;
margin-left: 4px;
}
.admin-pagination__ellipsis {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 20px;
color: #98a2b3;
font-weight: 700;
}
.pages button:disabled {
opacity: 0.45;
cursor: not-allowed;
}
</style>
@@ -1,7 +1,7 @@
<script setup lang="ts">
/**
* 分析看板:对接主账 /inquiry/dashboard/metrics。
* 指标 / 趋势 / 排名来自聚合接口;成本趋势本期主账未提供,展示空态不造假数。
* 指标 / 询价趋势 / 人员排名 / 成本趋势均来自主账聚合;禁止用 Mock 假线冒充生产。
*/
import { computed, onMounted, onUnmounted, ref } from "vue";
import { message } from "ant-design-vue";
@@ -13,8 +13,12 @@ const todayMetrics = ref<MetricCard[]>([]);
const inquiryTrend = ref<InquiryPoint[]>([]);
const rankingPeople = ref<RankingPerson[]>([]);
const rankingDepts = ref<string[]>([]);
/** 成本趋势主账暂无接口,保持空数组避免 Mock 假线。 */
/** 成本趋势:主账 costRoutes(近 7 日线路均价)。 */
const costRoutes = ref<CostRoute[]>([]);
const costDateLabels = ref<string[]>([]);
const COST_PLOT_TOP = 36;
const COST_PLOT_BOTTOM = 186;
const now = new Date();
const isoDay = now.toISOString().slice(0, 10);
@@ -87,6 +91,15 @@ async function loadMetrics() {
rankingDepts.value = Array.from(
new Set(rankingPeople.value.map((p) => p.dept).filter((d) => d && d !== "-")),
);
const dates: string[] = Array.isArray(data.costDates) && data.costDates.length
? data.costDates.map((d: any) => String(d))
: inquiryTrend.value.map((p) => p.date);
costDateLabels.value = dates;
const rawRoutes = (data.costRoutes || []) as any[];
costRoutes.value = buildCostRouteViews(rawRoutes, dates);
selectedRoutes.value = costRoutes.value.map((r) => r.id);
const t = new Date();
const pad = (n: number) => String(n).padStart(2, "0");
updatedTime.value = `${pad(t.getHours())}:${pad(t.getMinutes())}`;
@@ -97,6 +110,76 @@ async function loadMetrics() {
}
}
/**
* 把主账 costRoutes.values 转成 SVG path + 数据点。
* 只有一天有金额时 path 只有 M 看不见线,必须画 points。
*/
function buildCostRouteViews(rawRoutes: any[], dates: string[]): CostRoute[] {
const n = Math.max(dates.length, 1);
const xs = dates.map((_, i) => (n === 1 ? 380 : 36 + (654 / (n - 1)) * i));
const parsed = rawRoutes.map((r) => {
const values = Array.isArray(r.values)
? r.values.map((v: any) => (v == null || v === "" ? null : Number(v)))
: dates.map(() => null);
while (values.length < dates.length) values.push(null);
return {
id: String(r.id || r.label || "route"),
label: String(r.label || r.id || "线路"),
color: String(r.color || "#2563eb"),
values: values.slice(0, dates.length) as Array<number | null>,
};
});
const nums = parsed.flatMap((r) => r.values.filter((v): v is number => v != null && !Number.isNaN(v) && v > 0));
const max = Math.max(...nums, 1);
// 单点时 min==max 会压成一条横线贴顶/贴底,留 10% 边距更易看见
let min = nums.length ? Math.min(...nums) : 0;
if (nums.length && max - min < max * 0.05) {
min = Math.max(0, min - max * 0.1);
}
const range = Math.max(max - min, 1);
const plotH = COST_PLOT_BOTTOM - COST_PLOT_TOP;
const yFor = (v: number) => COST_PLOT_BOTTOM - ((v - min) / range) * plotH;
return parsed.map((r) => {
const points: Array<{ x: number; y: number; index: number }> = [];
r.values.forEach((v, i) => {
if (v == null || Number.isNaN(v)) return;
points.push({ x: xs[i], y: yFor(v), index: i });
});
return {
...r,
path: pathFromValues(r.values, xs, yFor),
points,
};
});
}
/** 有值连线,无值断点(不造假插值)。 */
function pathFromValues(
values: Array<number | null>,
xs: number[],
yFor: (v: number) => number,
): string {
let d = "";
let started = false;
for (let i = 0; i < values.length; i++) {
const v = values[i];
if (v == null || Number.isNaN(v)) {
started = false;
continue;
}
const x = xs[i];
const y = yFor(v);
d += `${started ? "L" : "M"}${x.toFixed(1)} ${y.toFixed(1)} `;
started = true;
}
return d.trim();
}
function formatCost(v: number | null | undefined) {
if (v == null || Number.isNaN(v)) return "-";
return v.toLocaleString("zh-CN", { maximumFractionDigits: 2 });
}
const inquirySvg = computed(() => {
const points = inquiryTrend.value.length
? inquiryTrend.value
@@ -128,7 +211,30 @@ const rankingRows = computed(() => {
return rows.sort((a, b) => b.volumeNum - a.volumeNum || a.name.localeCompare(b.name, "zh-CN"));
});
const costDates = computed(() => inquiryTrend.value.map((p) => p.date));
const costDates = computed(() =>
costDateLabels.value.length ? costDateLabels.value : inquiryTrend.value.map((p) => p.date),
);
const costSampleDays = computed(() => {
const set = new Set<string>();
for (const r of costRoutes.value) {
r.values.forEach((v, i) => {
if (v != null && !Number.isNaN(v) && costDates.value[i]) set.add(costDates.value[i]);
});
}
return [...set];
});
const costFootnote = computed(() => {
if (!costRoutes.value.length) {
return "近 7 日暂无带金额的工单,有报价后将显示线路成本趋势";
}
const days = costSampleDays.value;
if (!days.length) {
return "已选线路在近 7 日无金额样本";
}
return `成本来自主账工单(成本价优先,缺省销售价);有金额日期:${days.join("、")},其余日悬停为 —`;
});
const costXs = computed(() => {
const n = Math.max(costDates.value.length, 1);
return costDates.value.map((_, i) => (n === 1 ? 380 : 36 + (654 / (n - 1)) * i));
@@ -180,17 +286,15 @@ function hideInquiryTip() {
chartTip.value.visible = false;
}
function showCostTip(date: string, x: number, wrapEl: HTMLElement | null) {
function showCostTip(date: string, x: number, dateIndex: number, wrapEl: HTMLElement | null) {
if (!wrapEl) return;
const box = wrapEl.getBoundingClientRect();
const routes = filteredRoutes.value;
const lines =
routes.length === 0
? ["未选择线路"]
: [
...routes.slice(0, 3).map((r) => r.label),
...(routes.length > 3 ? [`等 ${routes.length} 条`] : []),
];
: routes.slice(0, 6).map((r) => `${r.label}: ${formatCost(r.values[dateIndex])}`);
if (routes.length > 6) lines.push(`等 ${routes.length} 条`);
costTip.value = {
visible: true,
left: `${(x / 760) * box.width}px`,
@@ -434,11 +538,24 @@ onUnmounted(() => document.removeEventListener("click", onDocClick));
stroke-linecap="round"
stroke-linejoin="round"
/>
<!-- 有金额的日子画点:近 7 日往往只有一两天有价,单点折线不可见 -->
<g v-for="r in filteredRoutes" :key="'pts-' + r.id">
<circle
v-for="p in r.points"
:key="r.id + '-' + p.index"
:cx="p.x"
:cy="p.y"
r="4"
:fill="r.color"
stroke="#ffffff"
stroke-width="2"
/>
</g>
<g
v-for="(x, i) in costXs"
:key="'cx-' + costDates[i]"
class="cost-point"
@mouseenter="showCostTip(costDates[i], x, costWrap)"
@mouseenter="showCostTip(costDates[i], x, i, costWrap)"
@mouseleave="hideCostTip"
>
<rect :x="x - 40" y="28" width="80" height="165" fill="transparent" style="cursor: pointer" />
@@ -458,7 +575,7 @@ onUnmounted(() => document.removeEventListener("click", onDocClick));
</div>
</div>
<div class="dash-cost-footnote">
{{ costRoutes.length ? "成本数据来源于 TMS" : "成本趋势主账暂未开放,待接口就绪后展示" }}
{{ costFootnote }}
</div>
</div>
</div>
@@ -23,7 +23,12 @@ export type CostRoute = {
id: string;
label: string;
color: string;
/** 与 costDates 对齐;某日无样本为 null */
values: Array<number | null>;
/** 前端根据 values 生成的 SVG path */
path: string;
/** 有金额的日子画点(仅一天有数时折线不可见,靠点可见) */
points: Array<{ x: number; y: number; index: number }>;
};
/** 今日视角九指标(与原型默认展示一致) */
@@ -78,23 +83,14 @@ export const rankingDepts = [
"陆运产品部",
];
/** 成本趋势折线:路径坐标对齐原型 SVG */
/** 成本趋势折线:路径坐标对齐原型 SVG(仅作样式参考;正式数据来自主账) */
export const costRoutes: CostRoute[] = [
{ id: "cgo-kul", label: "CGO-KUL", color: "#2563eb", path: "M36 56 L145 51 L254 61 L363 46 L472 50 L581 41 L690 36" },
{ id: "szx-kul", label: "SZX-KUL", color: "#3b82f6", path: "M36 64 L145 59 L254 69 L363 54 L472 59 L581 49 L690 44" },
{ id: "hkg-kul", label: "HKG-KUL", color: "#0891b2", path: "M36 71 L145 66 L254 75 L363 61 L472 66 L581 56 L690 51" },
{ id: "nkg-kul", label: "NKG-KUL", color: "#0e7490", path: "M36 79 L145 74 L254 81 L363 69 L472 74 L581 64 L690 59" },
{ id: "hkg-del", label: "HKG-DEL", color: "#6366f1", path: "M36 86 L145 81 L254 89 L363 76 L472 81 L581 71 L690 66" },
{ id: "cgo-maa", label: "CGO-MAA", color: "#8b5cf6", path: "M36 94 L145 89 L254 96 L363 84 L472 89 L581 79 L690 74" },
{ id: "cgo-blr", label: "CGO-BLR", color: "#a855f7", path: "M36 101 L145 96 L254 104 L363 91 L472 96 L581 86 L690 81" },
{ id: "zuh-crk", label: "ZUH-CRK", color: "#c026d3", path: "M36 109 L145 104 L254 111 L363 99 L472 104 L581 94 L690 89" },
{ id: "wnz-cgk", label: "WNZ-CGK", color: "#db2777", path: "M36 116 L145 111 L254 119 L363 106 L472 111 L581 101 L690 96" },
{ id: "bkk", label: "BKK", color: "#e11d48", path: "M36 124 L145 119 L254 126 L363 114 L472 119 L581 109 L690 104" },
{ id: "waicai", label: "外采-空运", color: "#dc2626", path: "M36 131 L145 126 L254 134 L363 121 L472 126 L581 116 L690 111" },
{ id: "quanxian", label: "全线", color: "#ea580c", path: "M36 139 L145 134 L254 141 L363 129 L472 134 L581 124 L690 119" },
{ id: "haiyun", label: "海运全线", color: "#d97706", path: "M36 150 L145 146 L254 154 L363 144 L472 141 L581 136 L690 131" },
{ id: "zhonggang", label: "中港/中亚/中欧", color: "#65a30d", path: "M36 159 L145 156 L254 163 L363 154 L472 151 L581 146 L690 141" },
{ id: "changtu", label: "国内长途/零担", color: "#0d9488", path: "M36 169 L145 166 L254 173 L363 164 L472 161 L581 159 L690 154" },
{ id: "chengpei", label: "国内城配/拖车/打包", color: "#059669", path: "M36 179 L145 176 L254 181 L363 174 L472 171 L581 169 L690 164" },
{ id: "dongnan", label: "东南亚", color: "#0284c7", path: "M36 186 L145 184 L254 189 L363 181 L472 179 L581 176 L690 171" },
{
id: "cgo-kul",
label: "CGO-KUL",
color: "#2563eb",
values: [120, 118, 125, 110, 115, 105, 100],
path: "M36 56 L145 51 L254 61 L363 46 L472 50 L581 41 L690 36",
points: [],
},
];
@@ -1,21 +1,29 @@
<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";
import {
disableStaff,
getStaffCounts,
getStaffDepartments,
getStaffList,
getWecomDepartments,
saveStaff as saveStaffApi,
syncWecomStaff,
} from "/@/views/inquiry/staffRole/staffRole.api";
import ProtoIcon from "/@/views/inquiry/components/ProtoIcon.vue";
import AdminModal from "/@/views/inquiry/components/AdminModal.vue";
import AdminPagination from "/@/views/inquiry/components/AdminPagination.vue";
type StaffRole = "sales" | "air" | "sea" | "land";
/** 部门下拉:value 存短名,label 带层级缩进 */
type DeptOption = { id: number; name: string; label: string; depth: number };
type StaffRow = {
id: string;
name: string;
@@ -42,6 +50,9 @@ const roleLabelMap: Record<StaffRole, string> = {
const rows = ref<StaffRow[]>([]);
const loading = ref(false);
const total = ref(0);
const pageNo = ref(1);
const pageSize = ref(10);
const counts = ref({ all: 0, sales: 0, air: 0, sea: 0, land: 0 });
const tab = ref<"all" | StaffRole>("all");
const filters = reactive({
@@ -53,6 +64,12 @@ const filters = reactive({
const modalOpen = ref(false);
const editing = ref<StaffRow | null>(null);
const syncing = ref(false);
/** 部门树扁平列表:一级不缩进,子级前加空格 */
const deptOptions = ref<DeptOption[]>([]);
const deptLoading = ref(false);
const deptLoaded = ref(false);
const deptOpen = ref(false);
const form = reactive({
name: "",
wecomId: "",
@@ -66,6 +83,12 @@ const form = reactive({
const filtered = computed(() => rows.value);
const filteredDeptOptions = computed(() => {
const q = (form.dept || "").trim().toLowerCase();
if (!q) return deptOptions.value;
return deptOptions.value.filter((d) => d.name.toLowerCase().includes(q));
});
function colorFor(name: string) {
let h = 0;
for (let i = 0; i < name.length; i++) h = (h + name.charCodeAt(i) * (i + 1)) % AVATAR_COLORS.length;
@@ -115,11 +138,19 @@ async function loadList() {
roleCode,
routes: filters.route || undefined,
status: filters.status || undefined,
pageNo: 1,
pageSize: 200,
pageNo: pageNo.value,
pageSize: pageSize.value,
});
const records = res?.records || res?.result?.records || [];
const page = res?.records ? res : res?.result || {};
const records = page.records || [];
total.value = Number(page.total || records.length || 0);
rows.value = (records as any[]).map(mapRow);
const maxPage = Math.max(1, Math.ceil(total.value / pageSize.value) || 1);
if (pageNo.value > maxPage) {
pageNo.value = maxPage;
await loadList();
return;
}
await loadCounts();
} catch (e: any) {
message.error(e?.message || "加载员工失败");
@@ -132,13 +163,132 @@ let searchTimer: ReturnType<typeof setTimeout> | null = null;
watch(
() => [tab.value, filters.keyword, filters.role, filters.route, filters.status],
() => {
pageNo.value = 1;
if (searchTimer) clearTimeout(searchTimer);
searchTimer = setTimeout(() => loadList(), 200);
},
);
function onPageChange() {
loadList();
}
onMounted(() => loadList());
/** 企微根部门公司全称,下拉不展示 */
const ROOT_DEPT_NAME = "深圳市友通达供应链管理有限公司";
/**
* 按 parentId 排成树再前序展开。
* 展示:部门1 / 「 」子部门1 / 「 」子部门2 / 部门2(根公司不出现)。
* 保存仍用短名 name。
*/
function buildDeptTreeOptions(raw: any[]): DeptOption[] {
const nodes = (raw as any[])
.map((d) => ({
id: Number(d?.id),
name: String(d?.name || "").trim(),
parentId: Number(d?.parentId ?? d?.parentid ?? 0),
}))
.filter((d) => d.name && Number.isFinite(d.id) && d.id > 0)
.filter((d) => d.id !== 1 && d.parentId > 0 && d.name !== ROOT_DEPT_NAME);
const idSet = new Set(nodes.map((n) => n.id));
const children = new Map<number, typeof nodes>();
for (const n of nodes) {
const pid = idSet.has(n.parentId) ? n.parentId : 0;
const list = children.get(pid) || [];
list.push(n);
children.set(pid, list);
}
const out: DeptOption[] = [];
const walk = (pid: number, depth: number) => {
for (const n of children.get(pid) || []) {
out.push({
id: n.id,
name: n.name,
label: "\u00A0".repeat(depth) + n.name,
depth,
});
walk(n.id, depth + 1);
}
};
walk(0, 0);
return out;
}
/**
* 打开弹窗拉企微部门树;失败不挡手工保存。
* 优先 wecomDepartments(有 parentId 才能缩进);否则退回 departments 扁平短名。
*/
async function ensureDeptOptions() {
if (deptLoaded.value || deptLoading.value) {
ensureCurrentDeptOption();
return;
}
deptLoading.value = true;
try {
try {
const res: any = await getWecomDepartments();
const list = Array.isArray(res) ? res : res?.result || [];
deptOptions.value = buildDeptTreeOptions(list);
} catch {
const res: any = await getStaffDepartments();
const list = Array.isArray(res) ? res : res?.result || [];
deptOptions.value = (list as any[])
.map((x, i) => {
const name = String(x || "").trim();
return { id: -(i + 1), name, label: name, depth: 0 };
})
.filter((d) => d.name && d.name !== ROOT_DEPT_NAME);
}
deptLoaded.value = true;
} catch (e: any) {
message.warning(e?.message || "部门列表加载失败,可稍后重试");
} finally {
deptLoading.value = false;
ensureCurrentDeptOption();
}
}
function ensureCurrentDeptOption() {
const cur = (form.dept || "").trim();
if (!cur) return;
if (!deptOptions.value.some((d) => d.name === cur)) {
deptOptions.value = [{ id: -1, name: cur, label: cur, depth: 0 }, ...deptOptions.value];
}
}
let deptBlurTimer: ReturnType<typeof setTimeout> | null = null;
function pickDept(d: DeptOption) {
form.dept = d.name;
deptOpen.value = false;
}
function onDeptFocus() {
if (deptBlurTimer) {
clearTimeout(deptBlurTimer);
deptBlurTimer = null;
}
deptOpen.value = true;
ensureDeptOptions();
}
function onDeptBlur() {
deptBlurTimer = setTimeout(() => {
deptOpen.value = false;
}, 180);
}
watch(
() => modalOpen.value,
(open) => {
if (!open) deptOpen.value = false;
},
);
function openCreate() {
editing.value = null;
Object.assign(form, {
@@ -152,6 +302,8 @@ function openCreate() {
status: "active",
});
modalOpen.value = true;
deptLoaded.value = false;
ensureDeptOptions();
}
function openEdit(row: StaffRow) {
@@ -167,6 +319,8 @@ function openEdit(row: StaffRow) {
status: row.status,
});
modalOpen.value = true;
deptLoaded.value = false;
ensureDeptOptions();
}
async function saveStaff() {
@@ -213,8 +367,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 +394,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>
@@ -353,39 +518,66 @@ function routeTags(routes: string) {
</tbody>
</table>
</div>
<div class="pagination">
<span>共 <b style="color: #0f172a">{{ filtered.length }}</b> 名员工</span>
<div class="pages">
<button class="is-active" type="button">1</button>
</div>
</div>
<AdminPagination
v-model:page-no="pageNo"
v-model:page-size="pageSize"
:total="total"
unit="名员工"
:disabled="loading"
@change="onPageChange"
/>
</div>
<AdminModal
v-model:open="modalOpen"
:title="editing ? '编辑员工' : '新建员工'"
: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>
<div class="dept-combo">
<input
v-model="form.dept"
class="field"
type="text"
autocomplete="off"
:disabled="deptLoading"
:placeholder="deptLoading ? '加载部门…' : '请选择或输入部门'"
@focus="onDeptFocus"
@input="deptOpen = true"
@blur="onDeptBlur"
/>
<span class="dept-combo-arrow" aria-hidden="true">▾</span>
<ul v-show="deptOpen" class="dept-combo-list">
<li v-if="!filteredDeptOptions.length" class="is-empty">无匹配部门</li>
<li
v-for="d in filteredDeptOptions"
:key="d.id"
:class="{ 'is-active': d.name === form.dept }"
:style="{ paddingLeft: 10 + d.depth * 12 + 'px' }"
@mousedown.prevent="pickDept(d)"
>
{{ d.name }}
</li>
</ul>
</div>
</div>
<div class="form-row">
<label class="lbl">岗位 <span class="req">*</span></label>
<select v-model="form.role" class="field">
<option value="sales">销售</option>
<option value="air">航线·空运</option>
@@ -449,4 +641,61 @@ function routeTags(routes: string) {
accent-color: #155eef;
cursor: pointer;
}
.dept-combo {
position: relative;
width: 100%;
}
.dept-combo .field {
padding-right: 28px;
}
.dept-combo-arrow {
position: absolute;
right: 10px;
top: 50%;
transform: translateY(-50%);
pointer-events: none;
color: #64748b;
font-size: 10px;
line-height: 1;
}
.dept-combo-list {
position: absolute;
left: 0;
right: 0;
top: calc(100% + 4px);
z-index: 30;
max-height: 220px;
overflow: auto;
margin: 0;
padding: 4px 0;
list-style: none;
background: #fff;
border: 1px solid #cbd5e1;
border-radius: 7px;
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.12);
}
.dept-combo-list li {
padding: 6px 10px;
font-size: 11px;
font-weight: 600;
color: #172033;
cursor: pointer;
white-space: nowrap;
}
.dept-combo-list li:hover,
.dept-combo-list li.is-active {
background: #eff6ff;
color: #155eef;
}
.dept-combo-list li.is-empty {
color: #94a3b8;
cursor: default;
}
</style>
@@ -1,6 +1,7 @@
/**
* 员工岗位 API:对接主账 /inquiry/staffRole。
* 与 Jeecg /sys/user 分离——本页管企微岗位绑定。
* 与 Jeecg /sys/user 分离——本页管企微岗位绑定;同步走主账 /syncWecom,浏览器不直连企微。
* 部门下拉对齐旧项目:GET /inquiry/staffRole/departments → string[]。
*/
import { defHttp } from '/@/utils/http/axios';
@@ -11,6 +12,10 @@ enum Api {
edit = '/inquiry/staffRole/edit',
disable = '/inquiry/staffRole/disable',
queryById = '/inquiry/staffRole/queryById',
syncWecom = '/inquiry/staffRole/syncWecom',
/** 旧项目同名接口:部门短名全量 */
departments = '/inquiry/staffRole/departments',
wecomDepartments = '/inquiry/staffRole/wecomDepartments',
}
export const getStaffList = (params) => defHttp.get({ url: Api.list, params });
@@ -19,3 +24,12 @@ 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 });
/**
* 部门下拉(对齐旧项目):返回 string[] 短名全量,可搜索可滚动。
* 失败时勿挡手工填部门。
*/
export const getStaffDepartments = () => defHttp.get({ url: Api.departments });
/** @deprecated 结构列表;员工下拉请用 getStaffDepartments */
export const getWecomDepartments = () => defHttp.get({ url: Api.wecomDepartments });
@@ -284,41 +284,62 @@ body {
box-shadow: 0 0 0 3px rgba(21, 94, 239, 0.12) !important;
}
/* 日期范围:Ant RangePicker,一次弹层点选起止;外观对齐工具栏 select */
.toolbar-date-range {
display: inline-flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
height: 38px;
box-sizing: border-box;
padding: 0 8px;
border: 1px solid var(--line);
border-radius: 8px;
background: #ffffff;
flex-shrink: 0;
max-width: 220px;
}
.toolbar-date-range input[type="date"] {
width: 92px;
min-width: 0;
max-width: 92px;
height: 28px;
border: none;
background: transparent;
padding: 0;
font-size: 10px;
font-weight: 600;
color: #344054;
outline: none;
cursor: pointer;
.toolbar-date-range .toolbar-range-picker.ant-picker,
.toolbar-date-range .ant-picker {
height: 38px !important;
padding: 0 12px !important;
border: 1px solid var(--line) !important;
border-radius: 8px !important;
background: #ffffff !important;
box-shadow: none !important;
transition: border-color 0.15s ease, box-shadow 0.15s ease !important;
}
.toolbar-date-sep {
font-size: 10px;
font-weight: 700;
color: #98a2b3;
flex-shrink: 0;
line-height: 1;
.toolbar-date-range .ant-picker:hover {
border-color: #98a2b3 !important;
}
.toolbar-date-range .ant-picker-focused,
.toolbar-date-range .ant-picker:focus,
.toolbar-date-range .ant-picker.ant-picker-focused {
border-color: #155eef !important;
box-shadow: 0 0 0 3px rgba(21, 94, 239, 0.12) !important;
}
.toolbar-date-range .ant-picker-input > input {
font-size: 11px !important;
font-weight: 700 !important;
color: #344054 !important;
cursor: pointer !important;
}
.toolbar-date-range .ant-picker-input > input::placeholder {
color: #98a2b3 !important;
font-weight: 600 !important;
}
.toolbar-date-range .ant-picker-separator {
color: #98a2b3 !important;
font-size: 11px !important;
font-weight: 600 !important;
padding: 0 4px !important;
}
.toolbar-date-range .ant-picker-suffix {
color: #98a2b3 !important;
margin-left: 4px !important;
}
.toolbar-date-range .ant-picker-clear {
background: #ffffff !important;
}
.toolbar .toolbar-actions .btn {
@@ -770,6 +791,7 @@ body {
background: #ffffff !important;
font-size: 11px !important;
color: #475467 !important;
flex-wrap: wrap !important;
}
.pagination button {
@@ -778,6 +800,17 @@ body {
border-radius: 6px !important;
font-size: 10.5px !important;
font-weight: 700 !important;
border: 1px solid #e4e7ec !important;
background: #ffffff !important;
color: #344054 !important;
cursor: pointer !important;
padding: 0 8px !important;
}
.pagination .pages button.is-active {
border-color: #155eef !important;
background: #eff4ff !important;
color: #155eef !important;
}
.pagination .pages {
@@ -786,6 +819,11 @@ body {
gap: 4px;
}
.pagination b {
color: #0f172a;
font-weight: 800;
}
/* ---- section / 权限树 ---- */
.section-card {
padding: 18px 20px !important;
@@ -5,7 +5,7 @@
* 详情/关单弹窗必须用 AdminModal:本工程 ant-design-vue 3 的 a-modal 只认 visible,
* v-model:open 不会弹出(账号/角色页已统一走 AdminModal)。
*/
import { onMounted, reactive, ref, watch } from "vue";
import { computed, onMounted, reactive, ref, watch } from "vue";
import { message, Modal } from "ant-design-vue";
import {
getWorkOrderDetail,
@@ -15,6 +15,7 @@ import {
} from "/@/views/inquiry/workOrder/workOrder.api";
import ProtoIcon from "/@/views/inquiry/components/ProtoIcon.vue";
import AdminModal from "/@/views/inquiry/components/AdminModal.vue";
import AdminPagination from "/@/views/inquiry/components/AdminPagination.vue";
type TicketQuote = { source: string; price: string; highlight?: boolean };
type TicketAttach = { name: string; uploadedAt: string; uploader: string };
@@ -52,6 +53,8 @@ type TicketRow = {
const rows = ref<TicketRow[]>([]);
const loading = ref(false);
const total = ref(0);
const pageNo = ref(1);
const pageSize = ref(10);
const filters = reactive({
keyword: "",
status: "",
@@ -67,11 +70,57 @@ const closeOpen = ref(false);
const current = ref<TicketRow | null>(null);
const closeReason = ref("");
/**
* 创建时间范围:与 filters.dateFrom/dateTo 双向同步。
* RangePicker 一次弹层点选起止两天;valueFormat 固定 YYYY-MM-DD 给列表 API。
*/
const dateRange = computed({
get(): [string, string] | undefined {
if (!filters.dateFrom && !filters.dateTo) return undefined;
return [filters.dateFrom || "", filters.dateTo || ""];
},
set(v: [string, string] | null | undefined) {
filters.dateFrom = v?.[0] ? String(v[0]) : "";
filters.dateTo = v?.[1] ? String(v[1]) : "";
},
});
function dash(v: any) {
const s = (v ?? "").toString().trim();
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 +132,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 +226,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 +244,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 +275,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 {
@@ -153,13 +294,19 @@ async function loadList() {
bizType: filters.bizType || undefined,
dateFrom: filters.dateFrom || undefined,
dateTo: filters.dateTo || undefined,
pageNo: 1,
pageSize: 200,
pageNo: pageNo.value,
pageSize: pageSize.value,
});
const page = res?.records ? res : res?.result || {};
const records = page.records || [];
total.value = Number(page.total || records.length || 0);
rows.value = (records as any[]).map((t) => mapTicket(t));
// 删减后当前页可能越界,回退到最后一页
const maxPage = Math.max(1, Math.ceil(total.value / pageSize.value) || 1);
if (pageNo.value > maxPage) {
pageNo.value = maxPage;
await loadList();
}
} catch (e: any) {
message.error(e?.message || "加载工单失败");
} finally {
@@ -168,15 +315,21 @@ async function loadList() {
}
let timer: ReturnType<typeof setTimeout> | null = null;
/** 筛选变更:回到第 1 页再拉列表 */
watch(
() => ({ ...filters }),
() => {
pageNo.value = 1;
if (timer) clearTimeout(timer);
timer = setTimeout(() => loadList(), 250);
},
{ deep: true },
);
function onPageChange() {
loadList();
}
onMounted(() => loadList());
async function openDetail(row: TicketRow) {
@@ -291,9 +444,19 @@ function refresh() {
<option value="多段联运">多段联运</option>
</select>
<div class="toolbar-date-range" title="按创建时间筛选">
<input v-model="filters.dateFrom" type="date" aria-label="创建时间起" />
<span class="toolbar-date-sep">–</span>
<input v-model="filters.dateTo" type="date" aria-label="创建时间止" />
<a-range-picker
v-model:value="dateRange"
class="toolbar-range-picker"
value-format="YYYY-MM-DD"
separator="至"
:placeholder="['开始日期', '结束日期']"
:allow-clear="true"
:input-read-only="true"
>
<template #suffixIcon>
<ProtoIcon name="calendar" :size="14" />
</template>
</a-range-picker>
</div>
</div>
@@ -350,17 +513,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">
@@ -436,9 +595,14 @@ function refresh() {
</tbody>
</table>
</div>
<div class="pagination">
<span>共 <b style="color: #0f172a">{{ total }}</b> 条工单</span>
</div>
<AdminPagination
v-model:page-no="pageNo"
v-model:page-size="pageSize"
:total="total"
unit="条工单"
:disabled="loading"
@change="onPageChange"
/>
</div>
</div>
@@ -491,8 +655,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 +668,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 +734,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>