Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
11ec728cf4 | ||
|
|
9d4889c482 | ||
|
|
07c988e61f | ||
|
|
b1eba3facc | ||
|
|
38a25a4b40 |
+35
-2
@@ -22,8 +22,10 @@ import org.springframework.beans.factory.annotation.Autowired;
|
|||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
import javax.servlet.http.HttpServletRequest;
|
import javax.servlet.http.HttpServletRequest;
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.Date;
|
import java.util.Date;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
@@ -147,9 +149,40 @@ public class InquiryStaffRoleController extends JeecgController<InquiryStaffRole
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 企微部门下拉选项。浏览器禁止直连企微,只打本接口。
|
* 员工「所属部门」下拉(对齐旧项目 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("企微部门列表")
|
@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")
|
@RequiresPermissions("inquiry:staff:list")
|
||||||
@GetMapping("/wecomDepartments")
|
@GetMapping("/wecomDepartments")
|
||||||
public Result<List<WeComDepartment>> wecomDepartments() {
|
public Result<List<WeComDepartment>> wecomDepartments() {
|
||||||
|
|||||||
+2
-1
@@ -8,7 +8,8 @@ import java.util.Map;
|
|||||||
public interface IInquiryDashboardService {
|
public interface IInquiryDashboardService {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 返回看板指标:metrics 列表 + trend + rankings。
|
* 返回看板指标:metrics + trend + rankings + costDates/costRoutes。
|
||||||
|
* rankings 与工单列表同套发起人解析;costRoutes 为近 7 日线路均价(主账工单投影)。
|
||||||
*
|
*
|
||||||
* @param range today|7d|30d,默认 today
|
* @param range today|7d|30d,默认 today
|
||||||
*/
|
*/
|
||||||
|
|||||||
+308
-38
@@ -1,12 +1,14 @@
|
|||||||
package org.jeecg.modules.inquiry.service.impl;
|
package org.jeecg.modules.inquiry.service.impl;
|
||||||
|
|
||||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
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.domain.TicketStatuses;
|
||||||
import org.jeecg.modules.inquiry.entity.InquiryStaffRole;
|
import org.jeecg.modules.inquiry.entity.InquiryStaffRole;
|
||||||
import org.jeecg.modules.inquiry.entity.InquiryTicket;
|
import org.jeecg.modules.inquiry.entity.InquiryTicket;
|
||||||
import org.jeecg.modules.inquiry.mapper.InquiryStaffRoleMapper;
|
import org.jeecg.modules.inquiry.mapper.InquiryStaffRoleMapper;
|
||||||
import org.jeecg.modules.inquiry.mapper.InquiryTicketMapper;
|
import org.jeecg.modules.inquiry.mapper.InquiryTicketMapper;
|
||||||
import org.jeecg.modules.inquiry.service.IInquiryDashboardService;
|
import org.jeecg.modules.inquiry.service.IInquiryDashboardService;
|
||||||
|
import org.jeecg.modules.inquiry.support.InquiryTicketDisplaySupport;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
@@ -18,24 +20,41 @@ import java.time.LocalTime;
|
|||||||
import java.time.ZoneId;
|
import java.time.ZoneId;
|
||||||
import java.time.format.DateTimeFormatter;
|
import java.time.format.DateTimeFormatter;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
import java.util.Comparator;
|
||||||
import java.util.Date;
|
import java.util.Date;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 看板实时聚合实现(无快照依赖)。
|
* 看板实时聚合实现(无快照依赖)。
|
||||||
* <p>
|
* <p>
|
||||||
* 计算口径对齐后台原型:询价总数、待处理、TMS 命中率、异常完成率、手工报价占比、
|
* 计算口径:询价总数、待处理、TMS 命中率、异常完成率、手工报价占比、
|
||||||
* 已成交、成交率、反馈率;内部协同时长本期无协同表时返回「-」。
|
* 已成交、成交率、反馈率;内部协同时长本期无协同表时返回「-」。
|
||||||
|
* 人员排名与工单列表同一套发起人解析(企微 id → 员工姓名),禁止只认库内空 sales_name。
|
||||||
|
* 成本趋势:近 7 日工单按线路聚合均价(成本价优先,缺省用销售价),非浏览器直连 TMS。
|
||||||
* 线程:只读查询,无写锁。
|
* 线程:只读查询,无写锁。
|
||||||
*/
|
*/
|
||||||
@Service
|
@Service
|
||||||
public class InquiryDashboardServiceImpl implements IInquiryDashboardService {
|
public class InquiryDashboardServiceImpl implements IInquiryDashboardService {
|
||||||
|
|
||||||
private static final ZoneId ZONE = ZoneId.of("Asia/Shanghai");
|
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
|
@Autowired
|
||||||
private InquiryTicketMapper ticketMapper;
|
private InquiryTicketMapper ticketMapper;
|
||||||
@@ -91,59 +110,306 @@ public class InquiryDashboardServiceImpl implements IInquiryDashboardService {
|
|||||||
metricCards.add(card("accepted", "已成交工单", "状态=已成交的工单数量", String.valueOf(deal), false));
|
metricCards.add(card("accepted", "已成交工单", "状态=已成交的工单数量", String.valueOf(deal), false));
|
||||||
metricCards.add(card("dealRate", "报价成交率", "已成交数 ÷ 已报价相关数 × 100%", pct(deal, Math.max(quoted, 1)), false));
|
metricCards.add(card("dealRate", "报价成交率", "已成交数 ÷ 已报价相关数 × 100%", pct(deal, Math.max(quoted, 1)), false));
|
||||||
long feedbackNumer = deal + lost;
|
long feedbackNumer = deal + lost;
|
||||||
// 反馈率口径:已成交+未成交(相对周期工单)
|
|
||||||
metricCards.add(card("feedback", "报价反馈率", "(已成交 + 未成交)÷ 工单数 × 100%", pct(feedbackNumer, total), false));
|
metricCards.add(card("feedback", "报价反馈率", "(已成交 + 未成交)÷ 工单数 × 100%", pct(feedbackNumer, total), false));
|
||||||
metricCards.add(card("avgResp", "内部协同平均响应时长", "本期无协同明细表,暂无数据", "-", true));
|
metricCards.add(card("avgResp", "内部协同平均响应时长", "本期无协同明细表,暂无数据", "-", true));
|
||||||
|
|
||||||
// 近 7 日趋势(不受 range 限制,便于折线)
|
// 近 7 日趋势 + 成本:一次拉周内工单,避免 7 次 count
|
||||||
List<Map<String, Object>> trend = new ArrayList<>();
|
Date weekFrom = Date.from(today.minusDays(6).atStartOfDay(ZONE).toInstant());
|
||||||
DateTimeFormatter fmt = DateTimeFormatter.ofPattern("MM-dd");
|
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--) {
|
for (int i = 6; i >= 0; i--) {
|
||||||
LocalDate d = today.minusDays(i);
|
LocalDate d = today.minusDays(i);
|
||||||
Date dFrom = Date.from(d.atStartOfDay(ZONE).toInstant());
|
dayIndex.put(d, trendDates.size());
|
||||||
Date dTo = Date.from(LocalDateTime.of(d, LocalTime.MAX).atZone(ZONE).toInstant());
|
String label = d.format(DAY_FMT);
|
||||||
long cnt = ticketMapper.selectCount(new LambdaQueryWrapper<InquiryTicket>()
|
trendDates.add(label);
|
||||||
.ge(InquiryTicket::getCreateTime, dFrom)
|
|
||||||
.le(InquiryTicket::getCreateTime, dTo));
|
|
||||||
Map<String, Object> p = new HashMap<>();
|
Map<String, Object> p = new HashMap<>();
|
||||||
p.put("date", d.format(fmt));
|
p.put("date", label);
|
||||||
p.put("value", cnt);
|
p.put("value", 0L);
|
||||||
trend.add(p);
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
// 人员排名:按销售名聚合询价量
|
List<InquiryStaffRole> staffRows = staffRoleMapper.selectList(null);
|
||||||
Map<String, Long> bySales = tickets.stream()
|
Map<String, InquiryStaffRole> staffByWecom = new HashMap<>();
|
||||||
.filter(t -> t.getSalesName() != null && !t.getSalesName().isEmpty())
|
Map<String, InquiryStaffRole> staffByName = new HashMap<>();
|
||||||
.collect(Collectors.groupingBy(InquiryTicket::getSalesName, Collectors.counting()));
|
for (InquiryStaffRole s : staffRows) {
|
||||||
Map<String, InquiryStaffRole> staffByName = staffRoleMapper.selectList(null).stream()
|
if (s == null) {
|
||||||
.collect(Collectors.toMap(InquiryStaffRole::getName, s -> s, (a, b) -> a));
|
continue;
|
||||||
List<Map<String, Object>> rankings = bySales.entrySet().stream()
|
}
|
||||||
.sorted(Map.Entry.<String, Long>comparingByValue().reversed())
|
if (StringUtils.isNotBlank(s.getWecomId())) {
|
||||||
.limit(20)
|
staffByWecom.put(s.getWecomId().trim(), s);
|
||||||
.map(e -> {
|
}
|
||||||
Map<String, Object> row = new LinkedHashMap<>();
|
if (StringUtils.isNotBlank(s.getName())) {
|
||||||
row.put("name", e.getKey());
|
staffByName.putIfAbsent(s.getName().trim(), s);
|
||||||
InquiryStaffRole staff = staffByName.get(e.getKey());
|
}
|
||||||
if (staff != null) {
|
}
|
||||||
row.put("role", mapRankRole(staff.getRoleCode()));
|
|
||||||
row.put("dept", staff.getDepartName());
|
List<Map<String, Object>> rankings = buildRankings(tickets, staffByWecom, staffByName);
|
||||||
} else {
|
List<Map<String, Object>> costRoutes = buildCostRoutes(weekTickets, dayIndex, trendDates);
|
||||||
row.put("role", "sales");
|
|
||||||
row.put("dept", "-");
|
|
||||||
}
|
|
||||||
row.put("volumeNum", e.getValue());
|
|
||||||
return row;
|
|
||||||
})
|
|
||||||
.collect(Collectors.toList());
|
|
||||||
|
|
||||||
Map<String, Object> result = new LinkedHashMap<>();
|
Map<String, Object> result = new LinkedHashMap<>();
|
||||||
result.put("range", range == null ? "today" : range);
|
result.put("range", range == null ? "today" : range);
|
||||||
result.put("metrics", metricCards);
|
result.put("metrics", metricCards);
|
||||||
result.put("trend", trend);
|
result.put("trend", trend);
|
||||||
result.put("rankings", rankings);
|
result.put("rankings", rankings);
|
||||||
|
result.put("costDates", trendDates);
|
||||||
|
result.put("costRoutes", costRoutes);
|
||||||
return result;
|
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) {
|
private static boolean hasExc(String v) {
|
||||||
return v != null && !v.trim().isEmpty() && !"-".equals(v.trim());
|
return v != null && !v.trim().isEmpty() && !"-".equals(v.trim());
|
||||||
}
|
}
|
||||||
@@ -166,9 +432,13 @@ public class InquiryDashboardServiceImpl implements IInquiryDashboardService {
|
|||||||
return m;
|
return m;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 员工 roleCode → 看板筛选项 sales/route/product。 */
|
||||||
private static String mapRankRole(String roleCode) {
|
private static String mapRankRole(String roleCode) {
|
||||||
if ("air".equals(roleCode) || "sea".equals(roleCode) || "land".equals(roleCode)) {
|
if ("air".equals(roleCode)) {
|
||||||
return "air".equals(roleCode) ? "route" : "product";
|
return "route";
|
||||||
|
}
|
||||||
|
if ("sea".equals(roleCode) || "land".equals(roleCode)) {
|
||||||
|
return "product";
|
||||||
}
|
}
|
||||||
return "sales";
|
return "sales";
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-3
@@ -127,8 +127,9 @@ public class WeComAddressBookClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 拉取企微部门树(扁平列表,带 path 便于下拉展示)。
|
* 拉取企微 {@code department/list} 全量扁平列表(短名 + path)。
|
||||||
* <p>
|
* <p>
|
||||||
|
* 员工下拉与旧项目同源:整份通讯录,不截层级。顺序保持企微返回序。
|
||||||
* 副作用:出站 HTTP;失败抛 IllegalStateException。
|
* 副作用:出站 HTTP;失败抛 IllegalStateException。
|
||||||
*/
|
*/
|
||||||
public List<WeComDepartment> listDepartments() {
|
public List<WeComDepartment> listDepartments() {
|
||||||
@@ -163,8 +164,8 @@ public class WeComAddressBookClient {
|
|||||||
String path = buildDepartmentPath(id, byId);
|
String path = buildDepartmentPath(id, byId);
|
||||||
result.add(new WeComDepartment(id, name, parentId, path));
|
result.add(new WeComDepartment(id, name, parentId, path));
|
||||||
}
|
}
|
||||||
// 按路径排序,下拉更易扫
|
// 保持企微 department/list 原序(LinkedHashMap 插入序 = 接口返回序),
|
||||||
result.sort((a, b) -> StringUtils.defaultString(a.getPath()).compareTo(StringUtils.defaultString(b.getPath())));
|
// 不要按 path 重排,也不要截层级;员工下拉与旧项目一样用这份全量短名。
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+33
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,9 +18,13 @@ import {
|
|||||||
} from "/@/views/inquiry/account/account.api";
|
} from "/@/views/inquiry/account/account.api";
|
||||||
import ProtoIcon from "/@/views/inquiry/components/ProtoIcon.vue";
|
import ProtoIcon from "/@/views/inquiry/components/ProtoIcon.vue";
|
||||||
import AdminModal from "/@/views/inquiry/components/AdminModal.vue";
|
import AdminModal from "/@/views/inquiry/components/AdminModal.vue";
|
||||||
|
import AdminPagination from "/@/views/inquiry/components/AdminPagination.vue";
|
||||||
|
|
||||||
const rows = ref<AccountRow[]>([]);
|
const rows = ref<AccountRow[]>([]);
|
||||||
const loading = ref(false);
|
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 roleOptions = ref<{ value: string; label: string; tag: string }[]>([]);
|
||||||
const filters = reactive({ keyword: "", status: "" as "" | "active" | "disabled" });
|
const filters = reactive({ keyword: "", status: "" as "" | "active" | "disabled" });
|
||||||
const modalOpen = ref(false);
|
const modalOpen = ref(false);
|
||||||
@@ -35,15 +39,6 @@ const form = reactive({
|
|||||||
password: "",
|
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 modalTitle = computed(() => (editing.value ? "编辑账号" : "新建账号"));
|
||||||
const modalSub = computed(() =>
|
const modalSub = computed(() =>
|
||||||
editing.value ? `正在编辑:${editing.value.username}` : "创建账号并绑定角色",
|
editing.value ? `正在编辑:${editing.value.username}` : "创建账号并绑定角色",
|
||||||
@@ -67,8 +62,28 @@ async function loadList() {
|
|||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
if (!roleOptions.value.length) await loadRoles();
|
if (!roleOptions.value.length) await loadRoles();
|
||||||
const res: any = await listUsers({ pageNo: 1, pageSize: 200 });
|
const kw = filters.keyword.trim();
|
||||||
const records = res?.records || res?.result?.records || [];
|
// 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[] = [];
|
const mapped: AccountRow[] = [];
|
||||||
for (const u of records as any[]) {
|
for (const u of records as any[]) {
|
||||||
let roleId = "";
|
let roleId = "";
|
||||||
@@ -89,6 +104,11 @@ async function loadList() {
|
|||||||
mapped.push(mapUserToAccount(u, roleId, roleLabel, roleTag));
|
mapped.push(mapUserToAccount(u, roleId, roleLabel, roleTag));
|
||||||
}
|
}
|
||||||
rows.value = mapped;
|
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) {
|
} catch (e: any) {
|
||||||
message.error(e?.message || "加载账号失败");
|
message.error(e?.message || "加载账号失败");
|
||||||
} finally {
|
} 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());
|
onMounted(() => loadList());
|
||||||
|
|
||||||
function openCreate() {
|
function openCreate() {
|
||||||
@@ -225,13 +259,6 @@ function removeAccount(row: AccountRow) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(
|
|
||||||
() => filters.keyword,
|
|
||||||
() => {
|
|
||||||
/* 本地过滤即可,账号量通常很小 */
|
|
||||||
},
|
|
||||||
);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -250,7 +277,7 @@ watch(
|
|||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<div class="search">
|
<div class="search">
|
||||||
<ProtoIcon class="search-icon" name="search" :size="16" />
|
<ProtoIcon class="search-icon" name="search" :size="16" />
|
||||||
<input v-model="filters.keyword" placeholder="搜索账号、姓名、企微ID、手机号..." />
|
<input v-model="filters.keyword" placeholder="搜索账号 / 姓名 / 手机号..." />
|
||||||
</div>
|
</div>
|
||||||
<select v-model="filters.status">
|
<select v-model="filters.status">
|
||||||
<option value="">状态</option>
|
<option value="">状态</option>
|
||||||
@@ -276,10 +303,10 @@ watch(
|
|||||||
<tr v-if="loading">
|
<tr v-if="loading">
|
||||||
<td colspan="7" style="text-align: center; color: #94a3b8">加载中…</td>
|
<td colspan="7" style="text-align: center; color: #94a3b8">加载中…</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr v-else-if="!filtered.length">
|
<tr v-else-if="!rows.length">
|
||||||
<td colspan="7" style="text-align: center; color: #94a3b8">暂无账号</td>
|
<td colspan="7" style="text-align: center; color: #94a3b8">暂无账号</td>
|
||||||
</tr>
|
</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 class="account-username">{{ row.username }}</td>
|
||||||
<td>{{ row.name }}</td>
|
<td>{{ row.name }}</td>
|
||||||
<td>
|
<td>
|
||||||
@@ -312,14 +339,14 @@ watch(
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<div class="pagination">
|
<AdminPagination
|
||||||
<span>
|
v-model:page-no="pageNo"
|
||||||
共 <b style="color: #0f172a">{{ filtered.length }}</b> 个账号
|
v-model:page-size="pageSize"
|
||||||
</span>
|
:total="total"
|
||||||
<div class="pages">
|
unit="个账号"
|
||||||
<button class="is-active" type="button">1</button>
|
:disabled="loading"
|
||||||
</div>
|
@change="onPageChange"
|
||||||
</div>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AdminModal
|
<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">
|
<script setup lang="ts">
|
||||||
/**
|
/**
|
||||||
* 分析看板:对接主账 /inquiry/dashboard/metrics。
|
* 分析看板:对接主账 /inquiry/dashboard/metrics。
|
||||||
* 指标 / 趋势 / 排名来自聚合接口;成本趋势本期主账未提供,展示空态不造假数。
|
* 指标 / 询价趋势 / 人员排名 / 成本趋势均来自主账聚合;禁止用 Mock 假线冒充生产。
|
||||||
*/
|
*/
|
||||||
import { computed, onMounted, onUnmounted, ref } from "vue";
|
import { computed, onMounted, onUnmounted, ref } from "vue";
|
||||||
import { message } from "ant-design-vue";
|
import { message } from "ant-design-vue";
|
||||||
@@ -13,8 +13,12 @@ const todayMetrics = ref<MetricCard[]>([]);
|
|||||||
const inquiryTrend = ref<InquiryPoint[]>([]);
|
const inquiryTrend = ref<InquiryPoint[]>([]);
|
||||||
const rankingPeople = ref<RankingPerson[]>([]);
|
const rankingPeople = ref<RankingPerson[]>([]);
|
||||||
const rankingDepts = ref<string[]>([]);
|
const rankingDepts = ref<string[]>([]);
|
||||||
/** 成本趋势主账暂无接口,保持空数组避免 Mock 假线。 */
|
/** 成本趋势:主账 costRoutes(近 7 日线路均价)。 */
|
||||||
const costRoutes = ref<CostRoute[]>([]);
|
const costRoutes = ref<CostRoute[]>([]);
|
||||||
|
const costDateLabels = ref<string[]>([]);
|
||||||
|
|
||||||
|
const COST_PLOT_TOP = 36;
|
||||||
|
const COST_PLOT_BOTTOM = 186;
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const isoDay = now.toISOString().slice(0, 10);
|
const isoDay = now.toISOString().slice(0, 10);
|
||||||
@@ -87,6 +91,15 @@ async function loadMetrics() {
|
|||||||
rankingDepts.value = Array.from(
|
rankingDepts.value = Array.from(
|
||||||
new Set(rankingPeople.value.map((p) => p.dept).filter((d) => d && d !== "-")),
|
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 t = new Date();
|
||||||
const pad = (n: number) => String(n).padStart(2, "0");
|
const pad = (n: number) => String(n).padStart(2, "0");
|
||||||
updatedTime.value = `${pad(t.getHours())}:${pad(t.getMinutes())}`;
|
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 inquirySvg = computed(() => {
|
||||||
const points = inquiryTrend.value.length
|
const points = inquiryTrend.value.length
|
||||||
? inquiryTrend.value
|
? 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"));
|
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 costXs = computed(() => {
|
||||||
const n = Math.max(costDates.value.length, 1);
|
const n = Math.max(costDates.value.length, 1);
|
||||||
return costDates.value.map((_, i) => (n === 1 ? 380 : 36 + (654 / (n - 1)) * i));
|
return costDates.value.map((_, i) => (n === 1 ? 380 : 36 + (654 / (n - 1)) * i));
|
||||||
@@ -180,17 +286,15 @@ function hideInquiryTip() {
|
|||||||
chartTip.value.visible = false;
|
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;
|
if (!wrapEl) return;
|
||||||
const box = wrapEl.getBoundingClientRect();
|
const box = wrapEl.getBoundingClientRect();
|
||||||
const routes = filteredRoutes.value;
|
const routes = filteredRoutes.value;
|
||||||
const lines =
|
const lines =
|
||||||
routes.length === 0
|
routes.length === 0
|
||||||
? ["未选择线路"]
|
? ["未选择线路"]
|
||||||
: [
|
: routes.slice(0, 6).map((r) => `${r.label}: ${formatCost(r.values[dateIndex])}`);
|
||||||
...routes.slice(0, 3).map((r) => r.label),
|
if (routes.length > 6) lines.push(`等 ${routes.length} 条`);
|
||||||
...(routes.length > 3 ? [`等 ${routes.length} 条`] : []),
|
|
||||||
];
|
|
||||||
costTip.value = {
|
costTip.value = {
|
||||||
visible: true,
|
visible: true,
|
||||||
left: `${(x / 760) * box.width}px`,
|
left: `${(x / 760) * box.width}px`,
|
||||||
@@ -434,11 +538,24 @@ onUnmounted(() => document.removeEventListener("click", onDocClick));
|
|||||||
stroke-linecap="round"
|
stroke-linecap="round"
|
||||||
stroke-linejoin="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
|
<g
|
||||||
v-for="(x, i) in costXs"
|
v-for="(x, i) in costXs"
|
||||||
:key="'cx-' + costDates[i]"
|
:key="'cx-' + costDates[i]"
|
||||||
class="cost-point"
|
class="cost-point"
|
||||||
@mouseenter="showCostTip(costDates[i], x, costWrap)"
|
@mouseenter="showCostTip(costDates[i], x, i, costWrap)"
|
||||||
@mouseleave="hideCostTip"
|
@mouseleave="hideCostTip"
|
||||||
>
|
>
|
||||||
<rect :x="x - 40" y="28" width="80" height="165" fill="transparent" style="cursor: pointer" />
|
<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>
|
</div>
|
||||||
<div class="dash-cost-footnote">
|
<div class="dash-cost-footnote">
|
||||||
{{ costRoutes.length ? "成本数据来源于 TMS" : "成本趋势主账暂未开放,待接口就绪后展示" }}
|
{{ costFootnote }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -23,7 +23,12 @@ export type CostRoute = {
|
|||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
color: string;
|
color: string;
|
||||||
|
/** 与 costDates 对齐;某日无样本为 null */
|
||||||
|
values: Array<number | null>;
|
||||||
|
/** 前端根据 values 生成的 SVG path */
|
||||||
path: string;
|
path: string;
|
||||||
|
/** 有金额的日子画点(仅一天有数时折线不可见,靠点可见) */
|
||||||
|
points: Array<{ x: number; y: number; index: number }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 今日视角九指标(与原型默认展示一致) */
|
/** 今日视角九指标(与原型默认展示一致) */
|
||||||
@@ -78,23 +83,14 @@ export const rankingDepts = [
|
|||||||
"陆运产品部",
|
"陆运产品部",
|
||||||
];
|
];
|
||||||
|
|
||||||
/** 成本趋势折线:路径坐标对齐原型 SVG */
|
/** 成本趋势折线:路径坐标对齐原型 SVG(仅作样式参考;正式数据来自主账) */
|
||||||
export const costRoutes: CostRoute[] = [
|
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: "cgo-kul",
|
||||||
{ id: "hkg-kul", label: "HKG-KUL", color: "#0891b2", path: "M36 71 L145 66 L254 75 L363 61 L472 66 L581 56 L690 51" },
|
label: "CGO-KUL",
|
||||||
{ id: "nkg-kul", label: "NKG-KUL", color: "#0e7490", path: "M36 79 L145 74 L254 81 L363 69 L472 74 L581 64 L690 59" },
|
color: "#2563eb",
|
||||||
{ id: "hkg-del", label: "HKG-DEL", color: "#6366f1", path: "M36 86 L145 81 L254 89 L363 76 L472 81 L581 71 L690 66" },
|
values: [120, 118, 125, 110, 115, 105, 100],
|
||||||
{ id: "cgo-maa", label: "CGO-MAA", color: "#8b5cf6", path: "M36 94 L145 89 L254 96 L363 84 L472 89 L581 79 L690 74" },
|
path: "M36 56 L145 51 L254 61 L363 46 L472 50 L581 41 L690 36",
|
||||||
{ id: "cgo-blr", label: "CGO-BLR", color: "#a855f7", path: "M36 101 L145 96 L254 104 L363 91 L472 96 L581 86 L690 81" },
|
points: [],
|
||||||
{ 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" },
|
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { message, Modal } from "ant-design-vue";
|
|||||||
import {
|
import {
|
||||||
disableStaff,
|
disableStaff,
|
||||||
getStaffCounts,
|
getStaffCounts,
|
||||||
|
getStaffDepartments,
|
||||||
getStaffList,
|
getStaffList,
|
||||||
getWecomDepartments,
|
getWecomDepartments,
|
||||||
saveStaff as saveStaffApi,
|
saveStaff as saveStaffApi,
|
||||||
@@ -16,10 +17,12 @@ import {
|
|||||||
} from "/@/views/inquiry/staffRole/staffRole.api";
|
} from "/@/views/inquiry/staffRole/staffRole.api";
|
||||||
import ProtoIcon from "/@/views/inquiry/components/ProtoIcon.vue";
|
import ProtoIcon from "/@/views/inquiry/components/ProtoIcon.vue";
|
||||||
import AdminModal from "/@/views/inquiry/components/AdminModal.vue";
|
import AdminModal from "/@/views/inquiry/components/AdminModal.vue";
|
||||||
|
import AdminPagination from "/@/views/inquiry/components/AdminPagination.vue";
|
||||||
|
|
||||||
type StaffRole = "sales" | "air" | "sea" | "land";
|
type StaffRole = "sales" | "air" | "sea" | "land";
|
||||||
|
|
||||||
type WecomDeptOption = { id: number; name: string; path: string; parentId: number; depth: number };
|
/** 部门下拉:value 存短名,label 带层级缩进 */
|
||||||
|
type DeptOption = { id: number; name: string; label: string; depth: number };
|
||||||
|
|
||||||
type StaffRow = {
|
type StaffRow = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -47,6 +50,9 @@ const roleLabelMap: Record<StaffRole, string> = {
|
|||||||
|
|
||||||
const rows = ref<StaffRow[]>([]);
|
const rows = ref<StaffRow[]>([]);
|
||||||
const loading = ref(false);
|
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 counts = ref({ all: 0, sales: 0, air: 0, sea: 0, land: 0 });
|
||||||
const tab = ref<"all" | StaffRole>("all");
|
const tab = ref<"all" | StaffRole>("all");
|
||||||
const filters = reactive({
|
const filters = reactive({
|
||||||
@@ -59,9 +65,11 @@ const filters = reactive({
|
|||||||
const modalOpen = ref(false);
|
const modalOpen = ref(false);
|
||||||
const editing = ref<StaffRow | null>(null);
|
const editing = ref<StaffRow | null>(null);
|
||||||
const syncing = ref(false);
|
const syncing = ref(false);
|
||||||
const wecomDepts = ref<WecomDeptOption[]>([]);
|
/** 部门树扁平列表:一级不缩进,子级前加空格 */
|
||||||
const wecomDeptsLoading = ref(false);
|
const deptOptions = ref<DeptOption[]>([]);
|
||||||
const wecomDeptsLoaded = ref(false);
|
const deptLoading = ref(false);
|
||||||
|
const deptLoaded = ref(false);
|
||||||
|
const deptOpen = ref(false);
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
name: "",
|
name: "",
|
||||||
wecomId: "",
|
wecomId: "",
|
||||||
@@ -75,6 +83,12 @@ const form = reactive({
|
|||||||
|
|
||||||
const filtered = computed(() => rows.value);
|
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) {
|
function colorFor(name: string) {
|
||||||
let h = 0;
|
let h = 0;
|
||||||
for (let i = 0; i < name.length; i++) h = (h + name.charCodeAt(i) * (i + 1)) % AVATAR_COLORS.length;
|
for (let i = 0; i < name.length; i++) h = (h + name.charCodeAt(i) * (i + 1)) % AVATAR_COLORS.length;
|
||||||
@@ -124,11 +138,19 @@ async function loadList() {
|
|||||||
roleCode,
|
roleCode,
|
||||||
routes: filters.route || undefined,
|
routes: filters.route || undefined,
|
||||||
status: filters.status || undefined,
|
status: filters.status || undefined,
|
||||||
pageNo: 1,
|
pageNo: pageNo.value,
|
||||||
pageSize: 200,
|
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);
|
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();
|
await loadCounts();
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
message.error(e?.message || "加载员工失败");
|
message.error(e?.message || "加载员工失败");
|
||||||
@@ -141,71 +163,91 @@ let searchTimer: ReturnType<typeof setTimeout> | null = null;
|
|||||||
watch(
|
watch(
|
||||||
() => [tab.value, filters.keyword, filters.role, filters.route, filters.status],
|
() => [tab.value, filters.keyword, filters.role, filters.route, filters.status],
|
||||||
() => {
|
() => {
|
||||||
|
pageNo.value = 1;
|
||||||
if (searchTimer) clearTimeout(searchTimer);
|
if (searchTimer) clearTimeout(searchTimer);
|
||||||
searchTimer = setTimeout(() => loadList(), 200);
|
searchTimer = setTimeout(() => loadList(), 200);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
function onPageChange() {
|
||||||
|
loadList();
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => loadList());
|
onMounted(() => loadList());
|
||||||
|
|
||||||
|
/** 企微根部门公司全称,下拉不展示 */
|
||||||
|
const ROOT_DEPT_NAME = "深圳市友通达供应链管理有限公司";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 打开弹窗时拉企微部门;失败不挡手工保存,仅提示。
|
* 按 parentId 排成树再前序展开。
|
||||||
* 下拉对齐旧项目业务层:只展示「分公司」「旧组织」下的直接子部门
|
* 展示:部门1 / 「 」子部门1 / 「 」子部门2 / 部门2(根公司不出现)。
|
||||||
*(事业二部/元昇/武汉友通达/通多邦…),不要公司根下的一级中心。
|
* 保存仍用短名 name。
|
||||||
*/
|
*/
|
||||||
async function ensureWecomDepartments() {
|
function buildDeptTreeOptions(raw: any[]): DeptOption[] {
|
||||||
if (wecomDeptsLoaded.value || wecomDeptsLoading.value) {
|
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();
|
ensureCurrentDeptOption();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
wecomDeptsLoading.value = true;
|
deptLoading.value = true;
|
||||||
try {
|
try {
|
||||||
const res: any = await getWecomDepartments();
|
try {
|
||||||
const list = Array.isArray(res) ? res : res?.result || [];
|
const res: any = await getWecomDepartments();
|
||||||
const all: WecomDeptOption[] = (list as any[])
|
const list = Array.isArray(res) ? res : res?.result || [];
|
||||||
.map((d) => {
|
deptOptions.value = buildDeptTreeOptions(list);
|
||||||
const path = String(d.path || d.name || "");
|
} catch {
|
||||||
const name = String(d.name || path);
|
const res: any = await getStaffDepartments();
|
||||||
const parentId = Number(d.parentId ?? d.parentid ?? 0);
|
const list = Array.isArray(res) ? res : res?.result || [];
|
||||||
const depth = path ? path.split(" / ").length - 1 : 0;
|
deptOptions.value = (list as any[])
|
||||||
return {
|
.map((x, i) => {
|
||||||
id: Number(d.id),
|
const name = String(x || "").trim();
|
||||||
name,
|
return { id: -(i + 1), name, label: name, depth: 0 };
|
||||||
path,
|
})
|
||||||
parentId,
|
.filter((d) => d.name && d.name !== ROOT_DEPT_NAME);
|
||||||
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));
|
|
||||||
}
|
}
|
||||||
|
deptLoaded.value = true;
|
||||||
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) {
|
} catch (e: any) {
|
||||||
message.warning(e?.message || "企微部门列表加载失败,可稍后重试");
|
message.warning(e?.message || "部门列表加载失败,可稍后重试");
|
||||||
} finally {
|
} finally {
|
||||||
wecomDeptsLoading.value = false;
|
deptLoading.value = false;
|
||||||
ensureCurrentDeptOption();
|
ensureCurrentDeptOption();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -213,17 +255,40 @@ async function ensureWecomDepartments() {
|
|||||||
function ensureCurrentDeptOption() {
|
function ensureCurrentDeptOption() {
|
||||||
const cur = (form.dept || "").trim();
|
const cur = (form.dept || "").trim();
|
||||||
if (!cur) return;
|
if (!cur) return;
|
||||||
const hit = wecomDepts.value.some((d) => d.name === cur || d.path === cur);
|
if (!deptOptions.value.some((d) => d.name === cur)) {
|
||||||
if (!hit) {
|
deptOptions.value = [{ id: -1, name: cur, label: cur, depth: 0 }, ...deptOptions.value];
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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() {
|
function openCreate() {
|
||||||
editing.value = null;
|
editing.value = null;
|
||||||
Object.assign(form, {
|
Object.assign(form, {
|
||||||
@@ -237,9 +302,8 @@ function openCreate() {
|
|||||||
status: "active",
|
status: "active",
|
||||||
});
|
});
|
||||||
modalOpen.value = true;
|
modalOpen.value = true;
|
||||||
// 每次打开重新拉一级部门,避免会话里还留着旧的「整棵树」缓存
|
deptLoaded.value = false;
|
||||||
wecomDeptsLoaded.value = false;
|
ensureDeptOptions();
|
||||||
ensureWecomDepartments();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function openEdit(row: StaffRow) {
|
function openEdit(row: StaffRow) {
|
||||||
@@ -255,8 +319,8 @@ function openEdit(row: StaffRow) {
|
|||||||
status: row.status,
|
status: row.status,
|
||||||
});
|
});
|
||||||
modalOpen.value = true;
|
modalOpen.value = true;
|
||||||
wecomDeptsLoaded.value = false;
|
deptLoaded.value = false;
|
||||||
ensureWecomDepartments();
|
ensureDeptOptions();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveStaff() {
|
async function saveStaff() {
|
||||||
@@ -454,12 +518,14 @@ function routeTags(routes: string) {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<div class="pagination">
|
<AdminPagination
|
||||||
<span>共 <b style="color: #0f172a">{{ filtered.length }}</b> 名员工</span>
|
v-model:page-no="pageNo"
|
||||||
<div class="pages">
|
v-model:page-size="pageSize"
|
||||||
<button class="is-active" type="button">1</button>
|
:total="total"
|
||||||
</div>
|
unit="名员工"
|
||||||
</div>
|
:disabled="loading"
|
||||||
|
@change="onPageChange"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AdminModal
|
<AdminModal
|
||||||
@@ -483,10 +549,32 @@ function routeTags(routes: string) {
|
|||||||
</div>
|
</div>
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<label class="lbl">所属部门</label>
|
<label class="lbl">所属部门</label>
|
||||||
<select v-model="form.dept" class="field" :disabled="wecomDeptsLoading">
|
<div class="dept-combo">
|
||||||
<option value="">{{ wecomDeptsLoading ? "加载企微部门…" : "请选择部门" }}</option>
|
<input
|
||||||
<option v-for="d in wecomDepts" :key="d.id + '-' + d.name" :value="d.name">{{ d.name }}</option>
|
v-model="form.dept"
|
||||||
</select>
|
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>
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<label class="lbl">岗位 <span class="req">*</span></label>
|
<label class="lbl">岗位 <span class="req">*</span></label>
|
||||||
@@ -553,4 +641,61 @@ function routeTags(routes: string) {
|
|||||||
accent-color: #155eef;
|
accent-color: #155eef;
|
||||||
cursor: pointer;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* 员工岗位 API:对接主账 /inquiry/staffRole。
|
* 员工岗位 API:对接主账 /inquiry/staffRole。
|
||||||
* 与 Jeecg /sys/user 分离——本页管企微岗位绑定;同步走主账 /syncWecom,浏览器不直连企微。
|
* 与 Jeecg /sys/user 分离——本页管企微岗位绑定;同步走主账 /syncWecom,浏览器不直连企微。
|
||||||
|
* 部门下拉对齐旧项目:GET /inquiry/staffRole/departments → string[]。
|
||||||
*/
|
*/
|
||||||
import { defHttp } from '/@/utils/http/axios';
|
import { defHttp } from '/@/utils/http/axios';
|
||||||
|
|
||||||
@@ -12,6 +13,8 @@ enum Api {
|
|||||||
disable = '/inquiry/staffRole/disable',
|
disable = '/inquiry/staffRole/disable',
|
||||||
queryById = '/inquiry/staffRole/queryById',
|
queryById = '/inquiry/staffRole/queryById',
|
||||||
syncWecom = '/inquiry/staffRole/syncWecom',
|
syncWecom = '/inquiry/staffRole/syncWecom',
|
||||||
|
/** 旧项目同名接口:部门短名全量 */
|
||||||
|
departments = '/inquiry/staffRole/departments',
|
||||||
wecomDepartments = '/inquiry/staffRole/wecomDepartments',
|
wecomDepartments = '/inquiry/staffRole/wecomDepartments',
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -23,5 +26,10 @@ export const disableStaff = (id: string) => defHttp.post({ url: Api.disable, par
|
|||||||
export const getStaffById = (id: string) => defHttp.get({ url: Api.queryById, params: { id } });
|
export const getStaffById = (id: string) => defHttp.get({ url: Api.queryById, params: { id } });
|
||||||
/** 主账拉企微通讯录 upsert;返回 { fetched, created, updated, skipped, message } */
|
/** 主账拉企微通讯录 upsert;返回 { fetched, created, updated, skipped, message } */
|
||||||
export const syncWecomStaff = () => defHttp.post({ url: Api.syncWecom });
|
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 });
|
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;
|
box-shadow: 0 0 0 3px rgba(21, 94, 239, 0.12) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 日期范围:Ant RangePicker,一次弹层点选起止;外观对齐工具栏 select */
|
||||||
.toolbar-date-range {
|
.toolbar-date-range {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
flex-shrink: 0;
|
||||||
height: 38px;
|
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"] {
|
.toolbar-date-range .toolbar-range-picker.ant-picker,
|
||||||
width: 92px;
|
.toolbar-date-range .ant-picker {
|
||||||
min-width: 0;
|
height: 38px !important;
|
||||||
max-width: 92px;
|
padding: 0 12px !important;
|
||||||
height: 28px;
|
border: 1px solid var(--line) !important;
|
||||||
border: none;
|
border-radius: 8px !important;
|
||||||
background: transparent;
|
background: #ffffff !important;
|
||||||
padding: 0;
|
box-shadow: none !important;
|
||||||
font-size: 10px;
|
transition: border-color 0.15s ease, box-shadow 0.15s ease !important;
|
||||||
font-weight: 600;
|
|
||||||
color: #344054;
|
|
||||||
outline: none;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.toolbar-date-sep {
|
.toolbar-date-range .ant-picker:hover {
|
||||||
font-size: 10px;
|
border-color: #98a2b3 !important;
|
||||||
font-weight: 700;
|
}
|
||||||
color: #98a2b3;
|
|
||||||
flex-shrink: 0;
|
.toolbar-date-range .ant-picker-focused,
|
||||||
line-height: 1;
|
.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 {
|
.toolbar .toolbar-actions .btn {
|
||||||
@@ -770,6 +791,7 @@ body {
|
|||||||
background: #ffffff !important;
|
background: #ffffff !important;
|
||||||
font-size: 11px !important;
|
font-size: 11px !important;
|
||||||
color: #475467 !important;
|
color: #475467 !important;
|
||||||
|
flex-wrap: wrap !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pagination button {
|
.pagination button {
|
||||||
@@ -778,6 +800,17 @@ body {
|
|||||||
border-radius: 6px !important;
|
border-radius: 6px !important;
|
||||||
font-size: 10.5px !important;
|
font-size: 10.5px !important;
|
||||||
font-weight: 700 !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 {
|
.pagination .pages {
|
||||||
@@ -786,6 +819,11 @@ body {
|
|||||||
gap: 4px;
|
gap: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pagination b {
|
||||||
|
color: #0f172a;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
/* ---- section / 权限树 ---- */
|
/* ---- section / 权限树 ---- */
|
||||||
.section-card {
|
.section-card {
|
||||||
padding: 18px 20px !important;
|
padding: 18px 20px !important;
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
* 详情/关单弹窗必须用 AdminModal:本工程 ant-design-vue 3 的 a-modal 只认 visible,
|
* 详情/关单弹窗必须用 AdminModal:本工程 ant-design-vue 3 的 a-modal 只认 visible,
|
||||||
* v-model:open 不会弹出(账号/角色页已统一走 AdminModal)。
|
* 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 { message, Modal } from "ant-design-vue";
|
||||||
import {
|
import {
|
||||||
getWorkOrderDetail,
|
getWorkOrderDetail,
|
||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
} from "/@/views/inquiry/workOrder/workOrder.api";
|
} from "/@/views/inquiry/workOrder/workOrder.api";
|
||||||
import ProtoIcon from "/@/views/inquiry/components/ProtoIcon.vue";
|
import ProtoIcon from "/@/views/inquiry/components/ProtoIcon.vue";
|
||||||
import AdminModal from "/@/views/inquiry/components/AdminModal.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 TicketQuote = { source: string; price: string; highlight?: boolean };
|
||||||
type TicketAttach = { name: string; uploadedAt: string; uploader: string };
|
type TicketAttach = { name: string; uploadedAt: string; uploader: string };
|
||||||
@@ -52,6 +53,8 @@ type TicketRow = {
|
|||||||
const rows = ref<TicketRow[]>([]);
|
const rows = ref<TicketRow[]>([]);
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const total = ref(0);
|
const total = ref(0);
|
||||||
|
const pageNo = ref(1);
|
||||||
|
const pageSize = ref(10);
|
||||||
const filters = reactive({
|
const filters = reactive({
|
||||||
keyword: "",
|
keyword: "",
|
||||||
status: "",
|
status: "",
|
||||||
@@ -67,6 +70,21 @@ const closeOpen = ref(false);
|
|||||||
const current = ref<TicketRow | null>(null);
|
const current = ref<TicketRow | null>(null);
|
||||||
const closeReason = ref("");
|
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) {
|
function dash(v: any) {
|
||||||
const s = (v ?? "").toString().trim();
|
const s = (v ?? "").toString().trim();
|
||||||
return s ? s : "-";
|
return s ? s : "-";
|
||||||
@@ -276,13 +294,19 @@ async function loadList() {
|
|||||||
bizType: filters.bizType || undefined,
|
bizType: filters.bizType || undefined,
|
||||||
dateFrom: filters.dateFrom || undefined,
|
dateFrom: filters.dateFrom || undefined,
|
||||||
dateTo: filters.dateTo || undefined,
|
dateTo: filters.dateTo || undefined,
|
||||||
pageNo: 1,
|
pageNo: pageNo.value,
|
||||||
pageSize: 200,
|
pageSize: pageSize.value,
|
||||||
});
|
});
|
||||||
const page = res?.records ? res : res?.result || {};
|
const page = res?.records ? res : res?.result || {};
|
||||||
const records = page.records || [];
|
const records = page.records || [];
|
||||||
total.value = Number(page.total || records.length || 0);
|
total.value = Number(page.total || records.length || 0);
|
||||||
rows.value = (records as any[]).map((t) => mapTicket(t));
|
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) {
|
} catch (e: any) {
|
||||||
message.error(e?.message || "加载工单失败");
|
message.error(e?.message || "加载工单失败");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -291,15 +315,21 @@ async function loadList() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
/** 筛选变更:回到第 1 页再拉列表 */
|
||||||
watch(
|
watch(
|
||||||
() => ({ ...filters }),
|
() => ({ ...filters }),
|
||||||
() => {
|
() => {
|
||||||
|
pageNo.value = 1;
|
||||||
if (timer) clearTimeout(timer);
|
if (timer) clearTimeout(timer);
|
||||||
timer = setTimeout(() => loadList(), 250);
|
timer = setTimeout(() => loadList(), 250);
|
||||||
},
|
},
|
||||||
{ deep: true },
|
{ deep: true },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
function onPageChange() {
|
||||||
|
loadList();
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => loadList());
|
onMounted(() => loadList());
|
||||||
|
|
||||||
async function openDetail(row: TicketRow) {
|
async function openDetail(row: TicketRow) {
|
||||||
@@ -414,9 +444,19 @@ function refresh() {
|
|||||||
<option value="多段联运">多段联运</option>
|
<option value="多段联运">多段联运</option>
|
||||||
</select>
|
</select>
|
||||||
<div class="toolbar-date-range" title="按创建时间筛选">
|
<div class="toolbar-date-range" title="按创建时间筛选">
|
||||||
<input v-model="filters.dateFrom" type="date" aria-label="创建时间起" />
|
<a-range-picker
|
||||||
<span class="toolbar-date-sep">–</span>
|
v-model:value="dateRange"
|
||||||
<input v-model="filters.dateTo" type="date" aria-label="创建时间止" />
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -555,9 +595,14 @@ function refresh() {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<div class="pagination">
|
<AdminPagination
|
||||||
<span>共 <b style="color: #0f172a">{{ total }}</b> 条工单</span>
|
v-model:page-no="pageNo"
|
||||||
</div>
|
v-model:page-size="pageSize"
|
||||||
|
:total="total"
|
||||||
|
unit="条工单"
|
||||||
|
:disabled="loading"
|
||||||
|
@change="onPageChange"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user