feat(dashboard): add boss dashboard H5 and APIs

Implement the mobile dashboard frontend, admin overview APIs, report archive export, and local dev proxy so the boss dashboard can run against real backend data while preserving MSW demos.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
danaisuiyuan
2026-05-11 13:07:40 +08:00
parent 693c66c258
commit 403ffe0fde
40 changed files with 6767 additions and 0 deletions

View File

@@ -148,6 +148,8 @@ public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
.antMatchers("/api/admin/store/product/copy/**").permitAll()
.antMatchers("/api/admin/merchandise/select").permitAll()
.antMatchers("/api/admin/merchandise/update").permitAll()
// 老板驾驶舱独立 H5 页面接口,本机演示和报表归档使用
.antMatchers("/api/admin/dashboard/**").permitAll()
// 积分模块外部免认证只读接口(供 /integral-external/* 页面调用)
.antMatchers("/api/external/integral/**").permitAll()
// 除上面外的所有请求全部需要鉴权认证

View File

@@ -0,0 +1,50 @@
package com.zbkj.admin.controller;
import com.zbkj.common.response.dashboard.BossDashboardResponse;
import com.zbkj.common.result.CommonResult;
import com.zbkj.service.service.BossDashboardService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.nio.charset.StandardCharsets;
/**
* 老板经营驾驶舱
*/
@Slf4j
@RestController
@RequestMapping("api/admin/dashboard")
@Api(tags = "老板经营驾驶舱")
public class BossDashboardController {
@Autowired
private BossDashboardService bossDashboardService;
@ApiOperation(value = "老板驾驶舱概览")
@RequestMapping(value = "/overview", method = RequestMethod.GET)
public CommonResult<BossDashboardResponse> overview(@RequestParam(value = "date", required = false) String date) {
return CommonResult.success(bossDashboardService.overview(date));
}
@ApiOperation(value = "生成经营日报归档 HTML")
@RequestMapping(value = "/daily-report/archive", method = RequestMethod.GET)
public ResponseEntity<byte[]> dailyReportArchive(@RequestParam(value = "date", required = false) String date) {
BossDashboardResponse overview = bossDashboardService.overview(date);
String html = bossDashboardService.dailyReportArchiveHtml(date);
String filename = "dashboard-daily-report-" + overview.getBusinessDate() + ".html";
HttpHeaders headers = new HttpHeaders();
headers.setContentType(new MediaType("text", "html", StandardCharsets.UTF_8));
headers.add(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + filename + "\"");
return ResponseEntity.ok().headers(headers).body(html.getBytes(StandardCharsets.UTF_8));
}
}

View File

@@ -0,0 +1,106 @@
package com.zbkj.common.response.dashboard;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
/**
* 老板驾驶舱响应对象
*/
@Data
@ApiModel(value = "BossDashboardResponse", description = "老板驾驶舱响应对象")
public class BossDashboardResponse {
@ApiModelProperty(value = "业务日期")
private String businessDate;
@ApiModelProperty(value = "生成时间")
private String generatedAt;
@ApiModelProperty(value = "经营摘要")
private String summary;
@ApiModelProperty(value = "核心指标")
private List<KpiMetric> kpis = new ArrayList<>();
@ApiModelProperty(value = "资金池指标")
private List<KpiMetric> fundPool = new ArrayList<>();
@ApiModelProperty(value = "今日节点快报")
private List<TodaySnapshot> snapshots = new ArrayList<>();
@ApiModelProperty(value = "近 7 天趋势")
private List<TrendPoint> trends = new ArrayList<>();
@ApiModelProperty(value = "高价值用户排行")
private List<RankItem> userRanks = new ArrayList<>();
@ApiModelProperty(value = "团队贡献排行")
private List<RankItem> teamRanks = new ArrayList<>();
@ApiModelProperty(value = "高货值未成交商品排行")
private List<RankItem> productRanks = new ArrayList<>();
@ApiModelProperty(value = "风险预警")
private List<RiskAlert> risks = new ArrayList<>();
@Data
public static class KpiMetric {
private String key;
private String title;
private Object value;
private String unit;
private String trendLabel;
private BigDecimal trendValue;
private String status = "normal";
private Boolean featured = false;
}
@Data
public static class TodaySnapshot {
private String slot;
private String title;
private String status;
private String generatedAt;
private String message;
private Integer purchaseUsers = 0;
private Integer orderCount = 0;
private BigDecimal dealAmount = BigDecimal.ZERO;
private BigDecimal paidAmount = BigDecimal.ZERO;
private Integer newMerchandiseCount = 0;
private BigDecimal selfBonusChange = BigDecimal.ZERO;
private BigDecimal shareBonusChange = BigDecimal.ZERO;
}
@Data
public static class TrendPoint {
private String date;
private BigDecimal amount = BigDecimal.ZERO;
private Integer orders = 0;
private Integer newUsers = 0;
private BigDecimal bonus = BigDecimal.ZERO;
}
@Data
public static class RankItem {
private String id;
private String name;
private BigDecimal value = BigDecimal.ZERO;
private String description;
private String badge;
}
@Data
public static class RiskAlert {
private String id;
private String level;
private String type;
private String title;
private String description;
private String discoveredAt;
}
}

View File

@@ -0,0 +1,25 @@
package com.zbkj.service.service;
import com.zbkj.common.response.dashboard.BossDashboardResponse;
/**
* 老板经营驾驶舱服务
*/
public interface BossDashboardService {
/**
* 获取老板经营驾驶舱数据
*
* @param date 业务日期,格式 yyyy-MM-dd为空时默认上一个工作日
* @return BossDashboardResponse
*/
BossDashboardResponse overview(String date);
/**
* 生成经营日报归档 HTML
*
* @param date 业务日期,格式 yyyy-MM-dd为空时默认上一个工作日
* @return standalone HTML
*/
String dailyReportArchiveHtml(String date);
}

View File

@@ -0,0 +1,564 @@
package com.zbkj.service.service.impl;
import cn.hutool.core.date.DateTime;
import cn.hutool.core.date.DateUtil;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.zbkj.common.model.consignment.WaMerchandise;
import com.zbkj.common.model.consignment.WaOrder;
import com.zbkj.common.model.consignment.WaSelfbonusLog;
import com.zbkj.common.model.consignment.WaSharebonusLog;
import com.zbkj.common.model.consignment.WaUsers;
import com.zbkj.common.model.consignment.WaWithdraw;
import com.zbkj.common.response.dashboard.BossDashboardResponse;
import com.zbkj.service.dao.consignment.WaMerchandiseDao;
import com.zbkj.service.dao.consignment.WaOrderDao;
import com.zbkj.service.dao.consignment.WaSelfbonusLogDao;
import com.zbkj.service.dao.consignment.WaSharebonusLogDao;
import com.zbkj.service.dao.consignment.WaUsersDao;
import com.zbkj.service.dao.consignment.WaWithdrawDao;
import com.zbkj.service.service.BossDashboardService;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.Calendar;
import java.util.Date;
import java.util.List;
import java.util.Map;
/**
* 老板经营驾驶舱服务实现
*/
@Service
public class BossDashboardServiceImpl implements BossDashboardService {
@Resource
private WaOrderDao waOrderDao;
@Resource
private WaMerchandiseDao waMerchandiseDao;
@Resource
private WaUsersDao waUsersDao;
@Resource
private WaSelfbonusLogDao waSelfbonusLogDao;
@Resource
private WaSharebonusLogDao waSharebonusLogDao;
@Resource
private WaWithdrawDao waWithdrawDao;
@Override
public BossDashboardResponse overview(String date) {
DateTime businessDate = StrUtil.isBlank(date) ? previousWorkday(DateUtil.date()) : DateUtil.parseDate(date);
DateTime previousDate = previousWorkday(businessDate);
DateRange businessRange = dayRange(businessDate);
DateRange previousRange = dayRange(previousDate);
DailyMetrics metrics = buildDailyMetrics(businessRange);
DailyMetrics previousMetrics = buildDailyMetrics(previousRange);
BossDashboardResponse response = new BossDashboardResponse();
response.setBusinessDate(businessDate.toString("yyyy-MM-dd"));
response.setGeneratedAt(DateUtil.formatDateTime(new Date()));
response.setSummary(buildSummary(metrics));
response.getKpis().add(metric("dealAmount", "上个工作日成交额", metrics.dealAmount, "", "较上一工作日", ratio(metrics.dealAmount, previousMetrics.dealAmount), statusByRatio(metrics.dealAmount, previousMetrics.dealAmount), true));
response.getKpis().add(metric("orderCount", "上个工作日订单数", metrics.orderCount, "", "较上一工作日", ratio(metrics.orderCount, previousMetrics.orderCount), statusByRatio(metrics.orderCount, previousMetrics.orderCount), false));
response.getKpis().add(metric("purchaseUsers", "采购用户", metrics.purchaseUsers, "", "较上一工作日", ratio(metrics.purchaseUsers, previousMetrics.purchaseUsers), statusByRatio(metrics.purchaseUsers, previousMetrics.purchaseUsers), false));
response.getKpis().add(metric("newUsers", "新增用户", metrics.newUsers, "", "较上一工作日", ratio(metrics.newUsers, previousMetrics.newUsers), statusByRatio(metrics.newUsers, previousMetrics.newUsers), false));
response.getKpis().add(metric("newMerchandise", "新增寄售商品", metrics.newMerchandiseCount, "", "较上一工作日", ratio(metrics.newMerchandiseCount, previousMetrics.newMerchandiseCount), statusByRatio(metrics.newMerchandiseCount, previousMetrics.newMerchandiseCount), false));
response.getKpis().add(metric("selfBonus", "个人奖金发放", metrics.selfBonus, "", "较上一工作日", ratio(metrics.selfBonus, previousMetrics.selfBonus), "normal", false));
response.getKpis().add(metric("shareBonus", "推广奖金发放", metrics.shareBonus, "", "较上一工作日", ratio(metrics.shareBonus, previousMetrics.shareBonus), "normal", false));
response.getKpis().add(metric("pendingAmount", "待支付/待结算", metrics.pendingAmount, "", "需关注", null, metrics.pendingAmount.compareTo(BigDecimal.ZERO) > 0 ? "warning" : "normal", false));
buildFundPool(response);
buildSnapshots(response);
buildTrends(response, businessDate);
buildRanks(response);
buildRisks(response);
return response;
}
@Override
public String dailyReportArchiveHtml(String date) {
BossDashboardResponse data = overview(date);
StringBuilder html = new StringBuilder();
html.append("<!doctype html><html lang=\"zh-CN\"><head><meta charset=\"utf-8\">");
html.append("<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">");
html.append("<title>经营日报归档 - ").append(escape(data.getBusinessDate())).append("</title>");
html.append("<style>");
html.append(":root{color:#132033;background:#fff6f1;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif}*{box-sizing:border-box}body{margin:0;background:radial-gradient(circle at top left,rgba(255,91,54,.18),transparent 30rem),#fff6f1}.page{max-width:820px;margin:0 auto;padding:28px 18px 40px}.hero{color:#fff;padding:26px;border-radius:0 0 28px 28px;background:linear-gradient(145deg,#ff5b36,#ff8b52),radial-gradient(circle at 90% 10%,rgba(255,176,0,.42),transparent 18rem);box-shadow:0 16px 40px rgba(255,91,54,.14)}.eyebrow{margin:0;color:rgba(255,255,255,.76);font-size:12px;font-weight:800;letter-spacing:.08em;text-transform:uppercase}.hero h1{margin:12px 0 8px;font-size:32px;line-height:1.1}.hero p{margin:0;color:rgba(255,255,255,.82);line-height:1.7}.meta{display:flex;flex-wrap:wrap;gap:8px;margin-top:16px}.meta span{padding:7px 12px;border-radius:999px;background:rgba(255,255,255,.16);border:1px solid rgba(255,255,255,.2);font-size:12px;font-weight:700}.section{margin-top:16px;padding:18px;background:#fff;border:1px solid rgba(19,32,51,.08);border-radius:24px;box-shadow:0 10px 28px rgba(22,47,80,.08)}.section h2{margin:0 0 14px;font-size:20px}.grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px}.card{padding:14px;border-radius:18px;background:#f6f9fb}.card small{display:block;color:#6b7a90}.card strong{display:block;margin-top:6px;font-size:22px}.list{display:grid;gap:10px}.item{padding:12px;border-radius:16px;background:#f6f9fb}.item strong{display:block}.item small,.item p{color:#6b7a90}.risk-red strong{color:#dc2626}.risk-yellow strong{color:#ffb000}.footer{margin-top:18px;color:#6b7a90;font-size:12px;text-align:center}@media(max-width:560px){.grid{grid-template-columns:1fr}.hero h1{font-size:28px}}");
html.append("</style></head><body><main class=\"page\">");
html.append("<header class=\"hero\"><p class=\"eyebrow\">Daily Report Archive</p><h1>经营日报归档</h1>");
html.append("<p>").append(escape(data.getSummary())).append("</p><div class=\"meta\">");
html.append("<span>数据日期:").append(escape(data.getBusinessDate())).append("</span>");
html.append("<span>生成时间:").append(escape(data.getGeneratedAt())).append("</span>");
html.append("<span>归档类型Standalone HTML</span></div></header>");
appendMetricsSection(html, "核心经营指标", data.getKpis());
appendTrendSection(html, data);
appendMetricsSection(html, "资金池摘要", data.getFundPool());
appendRankSection(html, "高价值用户", data.getUserRanks());
appendRankSection(html, "团队贡献排行", data.getTeamRanks());
appendRankSection(html, "高货值未成交商品", data.getProductRanks());
appendRiskSection(html, data);
html.append("<p class=\"footer\">本归档由经营驾驶舱实时数据生成,可独立保存和打开。</p>");
html.append("</main></body></html>");
return html.toString();
}
private DailyMetrics buildDailyMetrics(DateRange range) {
DailyMetrics metrics = new DailyMetrics();
metrics.dealAmount = sumOrderAmount(range.start, range.end, true, null);
metrics.orderCount = countOrders(range.start, range.end, null);
metrics.purchaseUsers = distinctBuyerCount(range.start, range.end, null);
metrics.newUsers = countUsers(range.start, range.end);
metrics.newMerchandiseCount = countMerchandise(range.start, range.end);
metrics.selfBonus = sumSelfBonus(range.start, range.end);
metrics.shareBonus = sumShareBonus(range.start, range.end);
metrics.pendingAmount = sumOrderAmount(range.start, range.end, false, null);
return metrics;
}
private void buildFundPool(BossDashboardResponse response) {
BigDecimal money = sumUsersDecimal("money");
BigDecimal coupon = sumUsersDecimal("coupon");
BigDecimal selfBonus = sumUsersDecimal("self_bonus");
BigDecimal shareBonus = sumUsersDecimal("share_bonus");
BigDecimal score = sumUsersDecimal("score");
BigDecimal pendingWithdraw = sumWithdrawAmount(0);
Integer pendingWithdrawCount = countWithdraw(0);
response.getFundPool().add(metric("balance", "余额总额", money, "", null, null, "normal", false));
response.getFundPool().add(metric("coupon", "优惠券总额", coupon, "", null, null, "normal", false));
response.getFundPool().add(metric("selfBonusPool", "个人奖金总额", selfBonus, "", null, null, selfBonus.compareTo(BigDecimal.ZERO) > 0 ? "warning" : "normal", false));
response.getFundPool().add(metric("shareBonusPool", "推广奖金总额", shareBonus, "", null, null, "normal", false));
response.getFundPool().add(metric("integral", "积分总额", score, "", null, null, "normal", false));
response.getFundPool().add(metric("withdrawPending", "待审核提现", pendingWithdraw, "", pendingWithdrawCount + "", null, pendingWithdrawCount > 0 ? "danger" : "normal", false));
}
private void buildSnapshots(BossDashboardResponse response) {
DateTime today = DateUtil.date();
DateRange morningRange = range(today, "00:00:00", "10:15:59");
DateRange afternoonRange = range(today, "10:16:00", "14:55:59");
DailyMetrics morningMetrics = buildDailyMetrics(morningRange);
BossDashboardResponse.TodaySnapshot morning = snapshot("1015", "10:15 上午快报", morningRange.end, "上午抢购节点已完成,上一日寄卖商品消化情况请关注成交额、付款和采购用户。", morningMetrics, "success");
response.getSnapshots().add(morning);
String afternoonStatus = new Date().after(afternoonRange.end) ? "success" : "pending";
String afternoonMessage = "success".equals(afternoonStatus)
? "下午寄卖/转卖节点已完成,请关注用户抢购商品的再次上架与转卖承接。"
: "下午寄卖/转卖节点尚未生成,预计 14:55 后可查看用户抢购商品的再次上架情况。";
DailyMetrics afternoonMetrics = "success".equals(afternoonStatus) ? buildDailyMetrics(afternoonRange) : new DailyMetrics();
response.getSnapshots().add(snapshot("1455", "14:55 下午快报", afternoonRange.end, afternoonMessage, afternoonMetrics, afternoonStatus));
}
private void buildTrends(BossDashboardResponse response, DateTime businessDate) {
for (int i = 6; i >= 0; i--) {
DateTime date = DateUtil.offsetDay(businessDate, -i);
DailyMetrics metrics = buildDailyMetrics(dayRange(date));
BossDashboardResponse.TrendPoint point = new BossDashboardResponse.TrendPoint();
point.setDate(date.toString("MM-dd"));
point.setAmount(metrics.dealAmount);
point.setOrders(metrics.orderCount);
point.setNewUsers(metrics.newUsers);
point.setBonus(metrics.selfBonus.add(metrics.shareBonus));
response.getTrends().add(point);
}
}
private void buildRanks(BossDashboardResponse response) {
QueryWrapper<WaUsers> userWrapper = new QueryWrapper<WaUsers>()
.select("id", "nickname", "mobile", "self_bonus", "share_bonus", "coupon", "score")
.orderByDesc("IFNULL(self_bonus,0) + IFNULL(share_bonus,0) + IFNULL(coupon,0)")
.last("limit 3");
List<WaUsers> users = waUsersDao.selectList(userWrapper);
for (int i = 0; i < users.size(); i++) {
WaUsers user = users.get(i);
BigDecimal value = defaultDecimal(user.getSelfBonus()).add(defaultDecimal(user.getShareBonus())).add(defaultDecimal(user.getCoupon()));
response.getUserRanks().add(rank("u" + user.getId(), displayName(user), value, maskMobile(user.getMobile()), i == 0 ? "高价值" : null));
}
QueryWrapper<WaUsers> teamWrapper = new QueryWrapper<WaUsers>()
.select("pid as id", "COUNT(id) as memberCount", "IFNULL(SUM(self_bonus),0) as selfBonus", "IFNULL(SUM(share_bonus),0) as shareBonus")
.isNotNull("pid")
.gt("pid", 0)
.groupBy("pid")
.orderByDesc("IFNULL(SUM(self_bonus),0) + IFNULL(SUM(share_bonus),0)")
.last("limit 3");
List<Map<String, Object>> teams = waUsersDao.selectMaps(teamWrapper);
for (int i = 0; i < teams.size(); i++) {
Map<String, Object> team = teams.get(i);
BigDecimal selfBonus = decimal(team.get("selfBonus"));
BigDecimal shareBonus = decimal(team.get("shareBonus"));
String leaderId = stringValue(team.get("id"));
WaUsers leader = waUsersDao.selectById(leaderId);
String leaderName = leader == null ? "团队 " + leaderId : displayName(leader);
response.getTeamRanks().add(rank("t" + leaderId, leaderName, selfBonus.add(shareBonus), "成员 " + intValue(team.get("memberCount")) + "", i == 0 ? "TOP1" : null));
}
QueryWrapper<WaMerchandise> productWrapper = new QueryWrapper<WaMerchandise>()
.select("id", "title", "price", "created_at")
.eq("status", 1)
.orderByDesc("price")
.last("limit 3");
List<WaMerchandise> products = waMerchandiseDao.selectList(productWrapper);
for (int i = 0; i < products.size(); i++) {
WaMerchandise product = products.get(i);
response.getProductRanks().add(rank("p" + product.getId(), StrUtil.isBlank(product.getTitle()) ? "未命名商品" : product.getTitle(), defaultDecimal(product.getPrice()), "高货值待成交", i == 0 ? "滞销" : null));
}
}
private void buildRisks(BossDashboardResponse response) {
BigDecimal pendingWithdraw = sumWithdrawAmount(0);
if (pendingWithdraw.compareTo(BigDecimal.ZERO) > 0) {
response.getRisks().add(risk("r1", "red", "资金", "待审核提现", "当前待审核提现 " + pendingWithdraw + " 元,建议今日处理。"));
}
Integer pendingOrders = countPendingOrders();
if (pendingOrders > 0) {
response.getRisks().add(risk("r2", "yellow", "订单", "待支付订单未处理", "当前存在 " + pendingOrders + " 笔待支付订单,请关注付款转化。"));
}
Integer hiddenProducts = countHiddenMerchandise();
if (hiddenProducts > 0) {
response.getRisks().add(risk("r3", "gray", "商品", "隐藏寄售商品", "当前存在 " + hiddenProducts + " 个隐藏寄售商品,可按需核查。"));
}
}
private BossDashboardResponse.KpiMetric metric(String key, String title, Object value, String unit, String trendLabel, BigDecimal trendValue, String status, Boolean featured) {
BossDashboardResponse.KpiMetric metric = new BossDashboardResponse.KpiMetric();
metric.setKey(key);
metric.setTitle(title);
metric.setValue(value);
metric.setUnit(unit);
metric.setTrendLabel(trendLabel);
metric.setTrendValue(trendValue);
metric.setStatus(status);
metric.setFeatured(featured);
return metric;
}
private BossDashboardResponse.TodaySnapshot snapshot(String slot, String title, Date generatedAt, String message, DailyMetrics metrics, String status) {
BossDashboardResponse.TodaySnapshot snapshot = new BossDashboardResponse.TodaySnapshot();
snapshot.setSlot(slot);
snapshot.setTitle(title);
snapshot.setStatus(status);
snapshot.setGeneratedAt(DateUtil.formatDateTime(generatedAt));
snapshot.setMessage(message);
snapshot.setPurchaseUsers(metrics.purchaseUsers);
snapshot.setOrderCount(metrics.orderCount);
snapshot.setDealAmount(metrics.dealAmount);
snapshot.setPaidAmount(metrics.dealAmount);
snapshot.setNewMerchandiseCount(metrics.newMerchandiseCount);
snapshot.setSelfBonusChange(metrics.selfBonus);
snapshot.setShareBonusChange(metrics.shareBonus);
return snapshot;
}
private BossDashboardResponse.RankItem rank(String id, String name, BigDecimal value, String description, String badge) {
BossDashboardResponse.RankItem rank = new BossDashboardResponse.RankItem();
rank.setId(id);
rank.setName(name);
rank.setValue(value);
rank.setDescription(description);
rank.setBadge(badge);
return rank;
}
private BossDashboardResponse.RiskAlert risk(String id, String level, String type, String title, String description) {
BossDashboardResponse.RiskAlert risk = new BossDashboardResponse.RiskAlert();
risk.setId(id);
risk.setLevel(level);
risk.setType(type);
risk.setTitle(title);
risk.setDescription(description);
risk.setDiscoveredAt(DateUtil.format(new Date(), "HH:mm"));
return risk;
}
private void appendMetricsSection(StringBuilder html, String title, List<BossDashboardResponse.KpiMetric> metrics) {
html.append("<section class=\"section\"><h2>").append(escape(title)).append("</h2><div class=\"grid\">");
for (BossDashboardResponse.KpiMetric metric : metrics) {
html.append("<article class=\"card\"><small>").append(escape(metric.getTitle())).append("</small>");
html.append("<strong>").append(formatMetric(metric.getValue(), metric.getUnit())).append("</strong>");
if (StrUtil.isNotBlank(metric.getTrendLabel()) || metric.getTrendValue() != null) {
html.append("<small>").append(escape(metric.getTrendLabel()));
if (metric.getTrendValue() != null) {
html.append(" ").append(metric.getTrendValue()).append("%");
}
html.append("</small>");
}
html.append("</article>");
}
html.append("</div></section>");
}
private void appendTrendSection(StringBuilder html, BossDashboardResponse data) {
html.append("<section class=\"section\"><h2>最近 7 天趋势</h2><div class=\"list\">");
for (BossDashboardResponse.TrendPoint point : data.getTrends()) {
html.append("<div class=\"item\"><strong>").append(escape(point.getDate())).append("").append(formatMoney(point.getAmount())).append("</strong>");
html.append("<small>").append(point.getOrders()).append(" 单 / 新增用户 ").append(point.getNewUsers()).append(" / 奖金 ").append(formatMoney(point.getBonus())).append("</small></div>");
}
html.append("</div></section>");
}
private void appendRankSection(StringBuilder html, String title, List<BossDashboardResponse.RankItem> ranks) {
html.append("<section class=\"section\"><h2>").append(escape(title)).append("</h2><div class=\"list\">");
if (ranks.isEmpty()) {
html.append("<div class=\"item\"><strong>暂无数据</strong><small>当前实时数据未生成该排行。</small></div>");
}
for (int i = 0; i < ranks.size(); i++) {
BossDashboardResponse.RankItem rank = ranks.get(i);
html.append("<div class=\"item\"><strong>").append(i + 1).append(". ").append(escape(rank.getName())).append(" · ").append(formatMoney(rank.getValue())).append("</strong>");
html.append("<small>").append(escape(rank.getDescription()));
if (StrUtil.isNotBlank(rank.getBadge())) {
html.append(" / ").append(escape(rank.getBadge()));
}
html.append("</small></div>");
}
html.append("</div></section>");
}
private void appendRiskSection(StringBuilder html, BossDashboardResponse data) {
html.append("<section class=\"section\"><h2>风险预警</h2><div class=\"list\">");
if (data.getRisks().isEmpty()) {
html.append("<div class=\"item\"><strong>暂无风险</strong><small>当前实时数据未触发风险预警。</small></div>");
}
for (BossDashboardResponse.RiskAlert risk : data.getRisks()) {
html.append("<div class=\"item risk-").append(escape(risk.getLevel())).append("\"><strong>");
html.append(escape(risk.getType())).append(" / ").append(escape(risk.getTitle())).append("</strong>");
html.append("<p>").append(escape(risk.getDescription())).append("</p>");
html.append("<small>发现时间:").append(escape(risk.getDiscoveredAt())).append("</small></div>");
}
html.append("</div></section>");
}
private BigDecimal sumOrderAmount(Date start, Date end, Boolean paidOnly, Boolean isResell) {
QueryWrapper<WaOrder> wrapper = new QueryWrapper<WaOrder>().select("IFNULL(SUM(total_money),0) as total").between("buy_time", start, end);
wrapper.eq("is_cancel", 0);
if (Boolean.TRUE.equals(paidOnly)) {
wrapper.ge("status", 1);
} else if (Boolean.FALSE.equals(paidOnly)) {
wrapper.eq("status", 0);
}
if (isResell != null) {
wrapper.eq("is_resell", isResell ? 1 : 0);
}
return aggregateDecimal(waOrderDao.selectMaps(wrapper), "total");
}
private Integer countOrders(Date start, Date end, Boolean isResell) {
QueryWrapper<WaOrder> wrapper = new QueryWrapper<WaOrder>().between("buy_time", start, end).eq("is_cancel", 0);
if (isResell != null) {
wrapper.eq("is_resell", isResell ? 1 : 0);
}
return waOrderDao.selectCount(wrapper);
}
private Integer distinctBuyerCount(Date start, Date end, Boolean isResell) {
QueryWrapper<WaOrder> wrapper = new QueryWrapper<WaOrder>().select("COUNT(DISTINCT buyer_id) as total").between("buy_time", start, end).eq("is_cancel", 0);
if (isResell != null) {
wrapper.eq("is_resell", isResell ? 1 : 0);
}
return aggregateInt(waOrderDao.selectMaps(wrapper), "total");
}
private Integer countUsers(Date start, Date end) {
return waUsersDao.selectCount(new QueryWrapper<WaUsers>().between("join_time", start, end));
}
private Integer countMerchandise(Date start, Date end) {
return waMerchandiseDao.selectCount(new QueryWrapper<WaMerchandise>().between("created_at", start, end));
}
private BigDecimal sumSelfBonus(Date start, Date end) {
QueryWrapper<WaSelfbonusLog> wrapper = new QueryWrapper<WaSelfbonusLog>().select("IFNULL(SUM(money),0) as total").eq("type", 1).between("created_at", start, end);
return aggregateDecimal(waSelfbonusLogDao.selectMaps(wrapper), "total");
}
private BigDecimal sumShareBonus(Date start, Date end) {
QueryWrapper<WaSharebonusLog> wrapper = new QueryWrapper<WaSharebonusLog>().select("IFNULL(SUM(money),0) as total").eq("type", 1).between("created_at", start, end);
return aggregateDecimal(waSharebonusLogDao.selectMaps(wrapper), "total");
}
private BigDecimal sumUsersDecimal(String column) {
QueryWrapper<WaUsers> wrapper = new QueryWrapper<WaUsers>().select("IFNULL(SUM(" + column + "),0) as total");
return aggregateDecimal(waUsersDao.selectMaps(wrapper), "total");
}
private BigDecimal sumWithdrawAmount(Integer status) {
QueryWrapper<WaWithdraw> wrapper = new QueryWrapper<WaWithdraw>().select("IFNULL(SUM(money),0) as total").eq("status", status);
return aggregateDecimal(waWithdrawDao.selectMaps(wrapper), "total");
}
private Integer countWithdraw(Integer status) {
return waWithdrawDao.selectCount(new QueryWrapper<WaWithdraw>().eq("status", status));
}
private Integer countPendingOrders() {
return waOrderDao.selectCount(new QueryWrapper<WaOrder>().eq("status", 0).eq("is_cancel", 0));
}
private Integer countHiddenMerchandise() {
return waMerchandiseDao.selectCount(new QueryWrapper<WaMerchandise>().eq("is_show", 0));
}
private BigDecimal ratio(BigDecimal current, BigDecimal previous) {
if (previous == null || previous.compareTo(BigDecimal.ZERO) == 0) {
return current != null && current.compareTo(BigDecimal.ZERO) > 0 ? BigDecimal.valueOf(100) : BigDecimal.ZERO;
}
return current.subtract(previous).multiply(BigDecimal.valueOf(100)).divide(previous, 1, RoundingMode.HALF_UP);
}
private BigDecimal ratio(Integer current, Integer previous) {
return ratio(BigDecimal.valueOf(current == null ? 0 : current), BigDecimal.valueOf(previous == null ? 0 : previous));
}
private String statusByRatio(BigDecimal current, BigDecimal previous) {
BigDecimal value = ratio(current, previous);
return value.compareTo(BigDecimal.ZERO) >= 0 ? "success" : "warning";
}
private String statusByRatio(Integer current, Integer previous) {
return statusByRatio(BigDecimal.valueOf(current == null ? 0 : current), BigDecimal.valueOf(previous == null ? 0 : previous));
}
private String buildSummary(DailyMetrics metrics) {
if (metrics.dealAmount.compareTo(BigDecimal.ZERO) == 0 && metrics.orderCount == 0) {
return "当前日期暂无成交数据,请关注抢购与寄卖节点是否正常生成。";
}
return "上个工作日成交 " + metrics.dealAmount + " 元,订单 " + metrics.orderCount + " 单,采购用户 " + metrics.purchaseUsers + " 人;请重点关注待支付、提现和寄售供给。";
}
private DateRange dayRange(DateTime date) {
return new DateRange(DateUtil.beginOfDay(date), DateUtil.endOfDay(date));
}
private DateTime previousWorkday(DateTime referenceDate) {
DateTime date = DateUtil.offsetDay(referenceDate, -1);
while (isWeekend(date)) {
date = DateUtil.offsetDay(date, -1);
}
return date;
}
private boolean isWeekend(DateTime date) {
int dayOfWeek = DateUtil.dayOfWeek(date);
return dayOfWeek == Calendar.SATURDAY || dayOfWeek == Calendar.SUNDAY;
}
private DateRange range(DateTime date, String startTime, String endTime) {
String day = date.toString("yyyy-MM-dd");
return new DateRange(DateUtil.parse(day + " " + startTime), DateUtil.parse(day + " " + endTime));
}
private BigDecimal aggregateDecimal(List<Map<String, Object>> maps, String key) {
if (maps == null || maps.isEmpty()) {
return BigDecimal.ZERO;
}
return decimal(maps.get(0).get(key));
}
private Integer aggregateInt(List<Map<String, Object>> maps, String key) {
if (maps == null || maps.isEmpty()) {
return 0;
}
return intValue(maps.get(0).get(key));
}
private BigDecimal decimal(Object value) {
if (value == null) {
return BigDecimal.ZERO;
}
if (value instanceof BigDecimal) {
return (BigDecimal) value;
}
return new BigDecimal(String.valueOf(value));
}
private BigDecimal defaultDecimal(BigDecimal value) {
return value == null ? BigDecimal.ZERO : value;
}
private Integer intValue(Object value) {
if (value == null) {
return 0;
}
if (value instanceof Number) {
return ((Number) value).intValue();
}
return Integer.parseInt(String.valueOf(value));
}
private String stringValue(Object value) {
return value == null ? "" : String.valueOf(value);
}
private String displayName(WaUsers user) {
if (StrUtil.isNotBlank(user.getNickname())) {
return user.getNickname();
}
if (StrUtil.isNotBlank(user.getUsername())) {
return user.getUsername();
}
return "用户 " + user.getId();
}
private String maskMobile(String mobile) {
if (StrUtil.isBlank(mobile) || mobile.length() < 7) {
return "手机号未完善";
}
return mobile.substring(0, 3) + "****" + mobile.substring(mobile.length() - 4);
}
private String formatMetric(Object value, String unit) {
if ("".equals(unit)) {
return formatMoney(decimal(value));
}
if (value == null) {
return "--";
}
return escape(String.valueOf(value)) + (unit == null ? "" : escape(unit));
}
private String formatMoney(BigDecimal value) {
return "¥" + defaultDecimal(value).setScale(2, RoundingMode.HALF_UP).toPlainString();
}
private String escape(String value) {
if (value == null) {
return "";
}
return value.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\"", "&quot;")
.replace("'", "&#39;");
}
private static class DailyMetrics {
private BigDecimal dealAmount = BigDecimal.ZERO;
private Integer orderCount = 0;
private Integer purchaseUsers = 0;
private Integer newUsers = 0;
private Integer newMerchandiseCount = 0;
private BigDecimal selfBonus = BigDecimal.ZERO;
private BigDecimal shareBonus = BigDecimal.ZERO;
private BigDecimal pendingAmount = BigDecimal.ZERO;
}
private static class DateRange {
private Date start;
private Date end;
private DateRange(Date start, Date end) {
this.start = start;
this.end = end;
}
}
}