Merge branch 'sxsy80' into czleilei240
This commit is contained in:
@@ -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()
|
||||
// 除上面外的所有请求全部需要鉴权认证
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace("\"", """)
|
||||
.replace("'", "'");
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,265 +1,22 @@
|
||||
package com.zbkj.service.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.zbkj.common.model.consignment.WaSelfbonusLog;
|
||||
import com.zbkj.common.model.user.User;
|
||||
import com.zbkj.common.model.user.UserIntegralRecord;
|
||||
import com.zbkj.common.utils.CrmebUtil;
|
||||
import com.zbkj.service.dao.UserDao;
|
||||
import com.zbkj.service.dao.UserIntegralRecordDao;
|
||||
import com.zbkj.service.dao.consignment.WaSelfbonusLogDao;
|
||||
import com.zbkj.service.service.UserService;
|
||||
import com.zbkj.service.service.WaSelfbonusSyncService;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.util.Date;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* 用户积分并发安全服务实现类
|
||||
* 专门处理高并发场景下的积分更新,避免数据库锁等待超时
|
||||
* 保留并发安全服务 bean 名称兼容历史调用。
|
||||
* 实际逻辑统一委托到 waSelfbonusSyncService,避免双实现规则漂移。
|
||||
*/
|
||||
@Slf4j
|
||||
@Service("userIntegralConcurrencyService")
|
||||
public class UserIntegralConcurrencyServiceImpl {
|
||||
|
||||
@Autowired
|
||||
private WaSelfbonusLogDao waSelfbonusLogDao;
|
||||
private WaSelfbonusSyncService waSelfbonusSyncService;
|
||||
|
||||
@Autowired
|
||||
private UserIntegralRecordDao userIntegralRecordDao;
|
||||
|
||||
@Autowired
|
||||
private UserDao userDao;
|
||||
|
||||
@Autowired
|
||||
private UserService userService;
|
||||
|
||||
// 使用ConcurrentHashMap来缓存正在处理的用户ID,防止重复处理
|
||||
private final ConcurrentHashMap<Integer, Object> processingUsers = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 同步个人奖金变动到用户积分 - 并发安全版本
|
||||
* 根据个人奖金变动记录,为对应的用户增加积分(奖金金额的50%)
|
||||
*/
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public Map<String, Object> syncSelfbonusToIntegral() {
|
||||
log.info("开始同步个人奖金变动到用户积分(并发安全版)");
|
||||
|
||||
int successCount = 0;
|
||||
int skipCount = 0;
|
||||
int failCount = 0;
|
||||
|
||||
try {
|
||||
// 查询最新的个人奖金变动记录
|
||||
LambdaQueryWrapper<WaSelfbonusLog> bonusLogWrapper = new LambdaQueryWrapper<>();
|
||||
bonusLogWrapper.orderByDesc(WaSelfbonusLog::getCreatedAt); // 按创建时间倒序
|
||||
bonusLogWrapper.last("LIMIT 500"); // 限制查询500条,避免一次性处理过多
|
||||
List<WaSelfbonusLog> bonusLogList = waSelfbonusLogDao.selectList(bonusLogWrapper);
|
||||
|
||||
for (WaSelfbonusLog bonusLog : bonusLogList) {
|
||||
try {
|
||||
// 检查该奖金记录是否已经处理过
|
||||
LambdaQueryWrapper<UserIntegralRecord> checkWrapper = new LambdaQueryWrapper<>();
|
||||
checkWrapper.eq(UserIntegralRecord::getWaSelfbonusLogid, bonusLog.getId());
|
||||
Integer existCount = userIntegralRecordDao.selectCount(checkWrapper);
|
||||
|
||||
if (existCount != null && existCount > 0) {
|
||||
log.debug("奖金记录已处理,跳过: bonusLogId={}, userId={}", bonusLog.getId(), bonusLog.getUserId());
|
||||
skipCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 获取用户ID
|
||||
Integer ebUserId = bonusLog.getUserId();
|
||||
|
||||
// 使用同步块确保同一用户不会被重复处理
|
||||
Object lock = processingUsers.computeIfAbsent(ebUserId, k -> new Object());
|
||||
synchronized (lock) {
|
||||
try {
|
||||
// 再次检查积分记录是否已存在(双重检查)
|
||||
existCount = userIntegralRecordDao.selectCount(checkWrapper);
|
||||
if (existCount != null && existCount > 0) {
|
||||
log.debug("奖金记录已在其他线程处理,跳过: bonusLogId={}", bonusLog.getId());
|
||||
skipCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 查询用户信息
|
||||
User user = userDao.selectById(ebUserId);
|
||||
if (user == null) {
|
||||
log.warn("未找到对应的系统用户,跳过: waUserId={}", bonusLog.getUserId());
|
||||
skipCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 验证奖金类型和金额
|
||||
if (!isValidBonusLog(bonusLog)) {
|
||||
log.debug("奖金记录不符合处理条件,跳过: bonusLogId={}, type={}, amount={}",
|
||||
bonusLog.getId(), bonusLog.getType(), bonusLog.getMoney());
|
||||
skipCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 计算积分值
|
||||
BigDecimal integralValue = calculateIntegralValue(bonusLog.getMoney());
|
||||
if (integralValue.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
log.debug("计算出的积分为0或负数,跳过: bonusLogId={}, integralValue={}",
|
||||
bonusLog.getId(), integralValue);
|
||||
skipCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 使用CAS方式更新积分,避免锁竞争
|
||||
Boolean updateResult = updateIntegralWithRetry(user.getUid(), integralValue, "add", 3);
|
||||
|
||||
if (!updateResult) {
|
||||
log.error("更新用户积分失败(重试后): userId={}, integralValue={}", user.getUid(), integralValue);
|
||||
failCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 插入积分记录
|
||||
UserIntegralRecord integralRecord = createUserIntegralRecord(user.getUid(), bonusLog, integralValue);
|
||||
int insertResult = userIntegralRecordDao.insert(integralRecord);
|
||||
if (insertResult <= 0) {
|
||||
log.error("插入积分记录失败: userId={}, bonusLogId={}", user.getUid(), bonusLog.getId());
|
||||
failCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
successCount++;
|
||||
log.info("成功同步奖金到积分: bonusLogId={}, userId={}, bonusAmount={}, integralValue={}",
|
||||
bonusLog.getId(), user.getUid(), bonusLog.getMoney(), integralValue);
|
||||
|
||||
} finally {
|
||||
// 清理锁对象
|
||||
processingUsers.remove(ebUserId);
|
||||
}
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
failCount++;
|
||||
log.error("处理奖金记录失败: bonusLogId={}, error={}", bonusLog.getId(), e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("total", bonusLogList.size());
|
||||
result.put("successCount", successCount);
|
||||
result.put("skipCount", skipCount);
|
||||
result.put("failCount", failCount);
|
||||
|
||||
log.info("同步个人奖金变动到用户积分完成(并发安全版): 总数={}, 成功={}, 跳过={}, 失败={}",
|
||||
bonusLogList.size(), successCount, skipCount, failCount);
|
||||
|
||||
result.put("message", "同步完成");
|
||||
return result;
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("同步个人奖金变动到用户积分异常", e);
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("message", "同步失败: " + e.getMessage());
|
||||
result.put("error", e.getMessage());
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证奖金记录是否符合处理条件
|
||||
*/
|
||||
private boolean isValidBonusLog(WaSelfbonusLog bonusLog) {
|
||||
// 只处理收入类型(type=1)
|
||||
if (bonusLog.getType() == null || bonusLog.getType() != 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 验证奖金金额有效
|
||||
if (bonusLog.getMoney() == null || bonusLog.getMoney().compareTo(BigDecimal.ZERO) <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算积分值
|
||||
*/
|
||||
private BigDecimal calculateIntegralValue(BigDecimal bonusAmount) {
|
||||
// 计算积分:奖金金额 * 50%,向下取整
|
||||
BigDecimal integralDecimal = bonusAmount.multiply(new BigDecimal("0.5"));
|
||||
return integralDecimal.setScale(3, RoundingMode.DOWN);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建积分记录对象
|
||||
*/
|
||||
private UserIntegralRecord createUserIntegralRecord(Integer userId, WaSelfbonusLog bonusLog, BigDecimal integralValue) {
|
||||
User user = userDao.selectById(userId); // 重新查询用户获取最新积分
|
||||
Integer newIntegral = user != null && user.getIntegral() != null ? user.getIntegral().intValue() : 0;
|
||||
|
||||
UserIntegralRecord integralRecord = new UserIntegralRecord();
|
||||
integralRecord.setUid(userId);
|
||||
integralRecord.setLinkId(String.valueOf(bonusLog.getId())); // 关联奖金记录ID
|
||||
integralRecord.setLinkType("selfbonus"); // 关联类型:个人奖金
|
||||
integralRecord.setType(1); // 类型:1-增加
|
||||
integralRecord.setTitle("个人奖金奖励");
|
||||
integralRecord.setIntegral(integralValue);
|
||||
integralRecord.setBalance(newIntegral); // 实际上应该是更新后的积分,这里可能需要调整
|
||||
integralRecord.setMark(String.format("个人奖金变动奖励,奖金金额:%.3f,积分:%d",
|
||||
bonusLog.getMoney(), integralValue.intValue()));
|
||||
integralRecord.setStatus(3); // 状态:3-完成
|
||||
integralRecord.setWaSelfbonusLogid(bonusLog.getId()); // 关联个人奖金记录ID
|
||||
integralRecord.setCreateTime(new Date());
|
||||
integralRecord.setUpdateTime(new Date());
|
||||
|
||||
return integralRecord;
|
||||
}
|
||||
|
||||
/**
|
||||
* 带重试机制的积分更新
|
||||
*/
|
||||
private Boolean updateIntegralWithRetry(Integer uid, BigDecimal integral, String type, int maxRetries) {
|
||||
int attempts = 0;
|
||||
Exception lastException = null;
|
||||
|
||||
while (attempts < maxRetries) {
|
||||
try {
|
||||
attempts++;
|
||||
|
||||
// 直接更新积分,不再依赖乐观锁
|
||||
Boolean result = userService.operationIntegral(uid, integral, BigDecimal.ZERO, type);
|
||||
|
||||
if (result) {
|
||||
return true;
|
||||
} else {
|
||||
log.warn("积分更新失败,准备重试 (attempt {}/{})", attempts, maxRetries);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
lastException = e;
|
||||
log.warn("积分更新异常,准备重试 (attempt {}/{}), error: {}", attempts, maxRetries, e.getMessage());
|
||||
|
||||
// 如果是数据库锁等待超时,等待一段时间再重试
|
||||
if (e.getMessage() != null && e.getMessage().contains("Lock wait timeout")) {
|
||||
try {
|
||||
Thread.sleep(100 * attempts); // 指数退避
|
||||
} catch (InterruptedException ie) {
|
||||
Thread.currentThread().interrupt();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.error("积分更新达到最大重试次数仍然失败,最后一次异常: ", lastException);
|
||||
return false;
|
||||
return waSelfbonusSyncService.syncSelfbonusToIntegral();
|
||||
}
|
||||
}
|
||||
@@ -11,8 +11,8 @@ import com.zbkj.service.service.UserService;
|
||||
import com.zbkj.service.service.WaSelfbonusSyncService;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
@@ -44,12 +44,14 @@ public class WaSelfbonusServiceImpl implements WaSelfbonusSyncService {
|
||||
@Autowired
|
||||
private UserService userService;
|
||||
|
||||
@Autowired
|
||||
private TransactionTemplate transactionTemplate;
|
||||
|
||||
/**
|
||||
* 同步个人奖金变动到用户积分
|
||||
* 根据个人奖金变动记录,为对应的用户增加积分(奖金金额的50%)
|
||||
*/
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public Map<String, Object> syncSelfbonusToIntegral() {
|
||||
log.info("开始同步个人奖金变动到用户积分");
|
||||
|
||||
@@ -66,101 +68,16 @@ public class WaSelfbonusServiceImpl implements WaSelfbonusSyncService {
|
||||
|
||||
for (WaSelfbonusLog bonusLog : bonusLogList) {
|
||||
try {
|
||||
// 检查该奖金记录是否已经处理过(通过 waSelfbonusLogid 字段查询积分记录)
|
||||
LambdaQueryWrapper<UserIntegralRecord> checkWrapper = new LambdaQueryWrapper<>();
|
||||
checkWrapper.eq(UserIntegralRecord::getWaSelfbonusLogid, bonusLog.getId());
|
||||
Integer existCount = userIntegralRecordDao.selectCount(checkWrapper);
|
||||
|
||||
if (existCount != null && existCount > 0) {
|
||||
// 已处理过,跳过
|
||||
log.debug("奖金记录已处理,跳过: bonusLogId={}, userId={}", bonusLog.getId(), bonusLog.getUserId());
|
||||
ProcessStatus processStatus = transactionTemplate.execute(status -> processBonusLogAtomically(bonusLog));
|
||||
if (processStatus == ProcessStatus.SKIP) {
|
||||
skipCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 根据 wa_users 的 user_id 查找对应的 eb_user 表的 uid
|
||||
// 注意:wa_users.id 对应 eb_user.uid(在同步时已建立关联)
|
||||
Integer ebUserId = bonusLog.getUserId(); // wa_users.id 就是 eb_user.uid
|
||||
|
||||
// 查询 eb_user 表中的用户
|
||||
User user = userDao.selectById(ebUserId);
|
||||
if (user == null) {
|
||||
log.warn("未找到对应的系统用户,跳过: waUserId={}", bonusLog.getUserId());
|
||||
skipCount++;
|
||||
if (processStatus == ProcessStatus.SUCCESS) {
|
||||
successCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 计算积分值:个人奖金变更金额的50%(只处理收入类型的奖金变动)
|
||||
if (bonusLog.getType() == null || bonusLog.getType() != 1) {
|
||||
// 只处理收入类型(type=1),支出类型不处理
|
||||
log.debug("跳过非收入类型的奖金变动: bonusLogId={}, type={}", bonusLog.getId(), bonusLog.getType());
|
||||
skipCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 奖金金额(收入为正数)
|
||||
BigDecimal bonusAmount = bonusLog.getMoney();
|
||||
if (bonusAmount == null || bonusAmount.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
log.debug("奖金金额无效,跳过: bonusLogId={}, money={}", bonusLog.getId(), bonusAmount);
|
||||
skipCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 计算积分:奖金金额 * 50%,向下取整
|
||||
BigDecimal integralDecimal = bonusAmount.multiply(new BigDecimal("0.5"));
|
||||
|
||||
BigDecimal integralValue = integralDecimal.setScale(3, RoundingMode.DOWN);
|
||||
|
||||
if (integralValue.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
log.debug("计算出的积分为0,跳过: bonusLogId={}, integralValue={}", bonusLog.getId(), integralValue);
|
||||
skipCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 更新用户积分 - 不再需要当前积分值作为乐观锁条件
|
||||
Boolean updateResult = userService.operationIntegral(
|
||||
user.getUid(),
|
||||
integralValue,
|
||||
BigDecimal.valueOf(0), // 不再使用当前积分作为乐观锁条件
|
||||
"add"
|
||||
);
|
||||
|
||||
if (!updateResult) {
|
||||
log.error("更新用户积分失败: userId={}, integralValue={}", user.getUid(), integralValue);
|
||||
failCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 重新查询用户获取最新积分
|
||||
user = userDao.selectById(user.getUid());
|
||||
Integer newIntegral = user.getIntegral() != null ? user.getIntegral().intValue() : 0;
|
||||
|
||||
// 新增积分记录
|
||||
UserIntegralRecord integralRecord = new UserIntegralRecord();
|
||||
integralRecord.setUid(user.getUid());
|
||||
integralRecord.setLinkId(String.valueOf(bonusLog.getId())); // 关联奖金记录ID
|
||||
integralRecord.setLinkType("selfbonus"); // 关联类型:个人奖金
|
||||
integralRecord.setType(1); // 类型:1-增加
|
||||
integralRecord.setTitle("个人奖金奖励");
|
||||
integralRecord.setIntegral(integralValue);
|
||||
integralRecord.setBalance(newIntegral);
|
||||
integralRecord.setMark(String.format("个人奖金变动奖励,奖金金额:%.3f,积分:%d",
|
||||
bonusAmount, integralValue.intValue()));
|
||||
integralRecord.setStatus(3); // 状态:3-完成
|
||||
integralRecord.setWaSelfbonusLogid(bonusLog.getId()); // 关联个人奖金记录ID
|
||||
integralRecord.setCreateTime(new Date());
|
||||
integralRecord.setUpdateTime(new Date());
|
||||
|
||||
int insertResult = userIntegralRecordDao.insert(integralRecord);
|
||||
if (insertResult <= 0) {
|
||||
log.error("插入积分记录失败: userId={}, bonusLogId={}", user.getUid(), bonusLog.getId());
|
||||
failCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
successCount++;
|
||||
log.info("成功同步奖金到积分: bonusLogId={}, userId={}, bonusAmount={}, integralValue={}",
|
||||
bonusLog.getId(), user.getUid(), bonusAmount, integralValue);
|
||||
failCount++;
|
||||
|
||||
} catch (Exception e) {
|
||||
failCount++;
|
||||
@@ -190,45 +107,102 @@ public class WaSelfbonusServiceImpl implements WaSelfbonusSyncService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 带重试机制的用户积分更新
|
||||
* 原子处理单条奖金日志:
|
||||
* 1) 先插入积分流水(受唯一索引保护)
|
||||
* 2) 插入成功后再更新用户总积分
|
||||
* 3) 回填本条流水的 balance
|
||||
*/
|
||||
private Boolean updateUserIntegralWithRetry(Integer uid, BigDecimal integralValue, int maxRetries) {
|
||||
int attempts = 0;
|
||||
Exception lastException = null;
|
||||
|
||||
while (attempts < maxRetries) {
|
||||
try {
|
||||
attempts++;
|
||||
|
||||
Boolean result = userService.operationIntegral(
|
||||
uid,
|
||||
integralValue,
|
||||
BigDecimal.ZERO, // 不再使用当前积分作为乐观锁条件
|
||||
"add"
|
||||
);
|
||||
|
||||
if (result) {
|
||||
return true;
|
||||
} else {
|
||||
log.warn("积分更新失败,准备重试 (attempt {}/{})", attempts, maxRetries);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
lastException = e;
|
||||
log.warn("积分更新异常,准备重试 (attempt {}/{}), error: {}", attempts, maxRetries, e.getMessage());
|
||||
|
||||
// 如果是数据库锁等待超时,等待一段时间再重试
|
||||
if (e.getMessage() != null && e.getMessage().contains("Lock wait timeout")) {
|
||||
try {
|
||||
Thread.sleep(100 * attempts); // 指数退避
|
||||
} catch (InterruptedException ie) {
|
||||
Thread.currentThread().interrupt();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
private ProcessStatus processBonusLogAtomically(WaSelfbonusLog bonusLog) {
|
||||
if (isAlreadyProcessed(bonusLog.getId())) {
|
||||
log.debug("奖金记录已处理,跳过: bonusLogId={}, userId={}", bonusLog.getId(), bonusLog.getUserId());
|
||||
return ProcessStatus.SKIP;
|
||||
}
|
||||
|
||||
log.error("积分更新达到最大重试次数仍然失败,最后一次异常: ", lastException);
|
||||
return false;
|
||||
Integer ebUserId = bonusLog.getUserId();
|
||||
User user = userDao.selectById(ebUserId);
|
||||
if (user == null) {
|
||||
log.warn("未找到对应的系统用户,跳过: waUserId={}", bonusLog.getUserId());
|
||||
return ProcessStatus.SKIP;
|
||||
}
|
||||
|
||||
if (bonusLog.getType() == null || bonusLog.getType() != 1) {
|
||||
log.debug("跳过非收入类型的奖金变动: bonusLogId={}, type={}", bonusLog.getId(), bonusLog.getType());
|
||||
return ProcessStatus.SKIP;
|
||||
}
|
||||
|
||||
BigDecimal bonusAmount = bonusLog.getMoney();
|
||||
if (bonusAmount == null || bonusAmount.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
log.debug("奖金金额无效,跳过: bonusLogId={}, money={}", bonusLog.getId(), bonusAmount);
|
||||
return ProcessStatus.SKIP;
|
||||
}
|
||||
|
||||
BigDecimal integralValue = bonusAmount.multiply(new BigDecimal("0.5")).setScale(3, RoundingMode.DOWN);
|
||||
if (integralValue.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
log.debug("计算出的积分为0,跳过: bonusLogId={}, integralValue={}", bonusLog.getId(), integralValue);
|
||||
return ProcessStatus.SKIP;
|
||||
}
|
||||
|
||||
UserIntegralRecord integralRecord = buildIntegralRecord(user.getUid(), bonusLog, bonusAmount, integralValue);
|
||||
try {
|
||||
int insertResult = userIntegralRecordDao.insert(integralRecord);
|
||||
if (insertResult <= 0) {
|
||||
throw new IllegalStateException("插入积分记录失败");
|
||||
}
|
||||
} catch (DuplicateKeyException duplicateKeyException) {
|
||||
// 数据库唯一索引兜底,保证多入口并发只处理一次
|
||||
log.info("奖金记录并发重复处理,已跳过: bonusLogId={}, userId={}", bonusLog.getId(), user.getUid());
|
||||
return ProcessStatus.SKIP;
|
||||
}
|
||||
|
||||
Boolean updateResult = userService.operationIntegral(
|
||||
user.getUid(),
|
||||
integralValue,
|
||||
BigDecimal.ZERO,
|
||||
"add"
|
||||
);
|
||||
|
||||
if (!updateResult) {
|
||||
throw new IllegalStateException(String.format("更新用户积分失败: userId=%s, integralValue=%s", user.getUid(), integralValue));
|
||||
}
|
||||
|
||||
User latestUser = userDao.selectById(user.getUid());
|
||||
Integer latestIntegral = latestUser != null && latestUser.getIntegral() != null ? latestUser.getIntegral().intValue() : 0;
|
||||
integralRecord.setBalance(latestIntegral);
|
||||
integralRecord.setUpdateTime(new Date());
|
||||
userIntegralRecordDao.updateById(integralRecord);
|
||||
|
||||
log.info("成功同步奖金到积分: bonusLogId={}, userId={}, bonusAmount={}, integralValue={}",
|
||||
bonusLog.getId(), user.getUid(), bonusAmount, integralValue);
|
||||
return ProcessStatus.SUCCESS;
|
||||
}
|
||||
|
||||
private boolean isAlreadyProcessed(Integer waSelfbonusLogId) {
|
||||
LambdaQueryWrapper<UserIntegralRecord> checkWrapper = new LambdaQueryWrapper<>();
|
||||
checkWrapper.eq(UserIntegralRecord::getWaSelfbonusLogid, waSelfbonusLogId);
|
||||
Integer existCount = userIntegralRecordDao.selectCount(checkWrapper);
|
||||
return existCount != null && existCount > 0;
|
||||
}
|
||||
|
||||
private UserIntegralRecord buildIntegralRecord(Integer uid, WaSelfbonusLog bonusLog, BigDecimal bonusAmount, BigDecimal integralValue) {
|
||||
UserIntegralRecord integralRecord = new UserIntegralRecord();
|
||||
integralRecord.setUid(uid);
|
||||
integralRecord.setLinkId(String.valueOf(bonusLog.getId()));
|
||||
integralRecord.setLinkType("selfbonus");
|
||||
integralRecord.setType(1);
|
||||
integralRecord.setTitle("个人奖金奖励");
|
||||
integralRecord.setIntegral(integralValue);
|
||||
integralRecord.setBalance(0);
|
||||
integralRecord.setMark(String.format("个人奖金变动奖励,奖金金额:%.3f,积分:%.3f",
|
||||
bonusAmount, integralValue));
|
||||
integralRecord.setStatus(3);
|
||||
integralRecord.setWaSelfbonusLogid(bonusLog.getId());
|
||||
integralRecord.setCreateTime(new Date());
|
||||
integralRecord.setUpdateTime(new Date());
|
||||
return integralRecord;
|
||||
}
|
||||
|
||||
private enum ProcessStatus {
|
||||
SUCCESS,
|
||||
SKIP
|
||||
}
|
||||
}
|
||||
|
||||
23
backend/sql/add_unique_index_uk_integral_selfbonus_log.sql
Normal file
23
backend/sql/add_unique_index_uk_integral_selfbonus_log.sql
Normal file
@@ -0,0 +1,23 @@
|
||||
-- Add strong idempotency guard for selfbonus -> integral conversion.
|
||||
-- This index guarantees one wa_selfbonus_log can map to at most one integral record.
|
||||
-- Prerequisite: clear duplicate wa_selfbonus_logid rows first.
|
||||
|
||||
-- Pre-checks
|
||||
SELECT wa_selfbonus_logid, COUNT(*) AS cnt
|
||||
FROM eb_user_integral_record
|
||||
WHERE wa_selfbonus_logid IS NOT NULL
|
||||
GROUP BY wa_selfbonus_logid
|
||||
HAVING COUNT(*) > 1
|
||||
LIMIT 20;
|
||||
|
||||
SELECT COUNT(*) AS zero_cnt
|
||||
FROM eb_user_integral_record
|
||||
WHERE wa_selfbonus_logid = 0;
|
||||
|
||||
-- Apply unique index
|
||||
ALTER TABLE eb_user_integral_record
|
||||
ADD UNIQUE KEY uk_integral_selfbonus_log (wa_selfbonus_logid);
|
||||
|
||||
-- Verify index exists
|
||||
SHOW INDEX FROM eb_user_integral_record
|
||||
WHERE Key_name = 'uk_integral_selfbonus_log';
|
||||
130
backend/sql/fix_duplicate_selfbonus_integral_records.sql
Normal file
130
backend/sql/fix_duplicate_selfbonus_integral_records.sql
Normal file
@@ -0,0 +1,130 @@
|
||||
-- Purpose:
|
||||
-- 1) Find duplicate selfbonus integral rows generated from same wa_selfbonus_log
|
||||
-- 2) Backup affected data
|
||||
-- 3) Keep min(id), delete duplicate rows
|
||||
-- 4) Resync eb_user.integral from remaining ledger rows
|
||||
-- 5) Rebuild integer balance snapshot for affected users
|
||||
--
|
||||
-- Notes:
|
||||
-- - Designed for MySQL 5.7
|
||||
-- - Run during low traffic window
|
||||
-- - Review backup table names before execution
|
||||
|
||||
-- 0) Preview duplicate groups
|
||||
SELECT uid,
|
||||
wa_selfbonus_logid,
|
||||
link_id,
|
||||
COUNT(*) AS cnt,
|
||||
SUM(integral) AS total_integral,
|
||||
MIN(id) AS keep_id,
|
||||
GROUP_CONCAT(id ORDER BY id) AS record_ids
|
||||
FROM eb_user_integral_record
|
||||
WHERE link_type = 'selfbonus'
|
||||
AND type = 1
|
||||
AND wa_selfbonus_logid IS NOT NULL
|
||||
GROUP BY uid, wa_selfbonus_logid, link_id
|
||||
HAVING COUNT(*) > 1;
|
||||
|
||||
-- 1) Backup duplicate rows and affected users
|
||||
DROP TABLE IF EXISTS backup_euir_selfbonus_dups_20260511_0959;
|
||||
CREATE TABLE backup_euir_selfbonus_dups_20260511_0959 AS
|
||||
SELECT e.*
|
||||
FROM eb_user_integral_record e
|
||||
JOIN (
|
||||
SELECT uid, wa_selfbonus_logid, link_id, MIN(id) AS keep_id, COUNT(*) AS cnt
|
||||
FROM eb_user_integral_record
|
||||
WHERE link_type = 'selfbonus'
|
||||
AND type = 1
|
||||
AND wa_selfbonus_logid IS NOT NULL
|
||||
GROUP BY uid, wa_selfbonus_logid, link_id
|
||||
HAVING COUNT(*) > 1
|
||||
) d
|
||||
ON d.uid = e.uid
|
||||
AND d.wa_selfbonus_logid = e.wa_selfbonus_logid
|
||||
AND d.link_id = e.link_id;
|
||||
|
||||
DROP TABLE IF EXISTS backup_eb_user_integral_before_fix_20260511_0959;
|
||||
CREATE TABLE backup_eb_user_integral_before_fix_20260511_0959 AS
|
||||
SELECT u.*
|
||||
FROM eb_user u
|
||||
WHERE u.uid IN (
|
||||
SELECT DISTINCT uid FROM backup_euir_selfbonus_dups_20260511_0959
|
||||
);
|
||||
|
||||
-- 2) Deduplicate + resync in one transaction
|
||||
START TRANSACTION;
|
||||
|
||||
DROP TEMPORARY TABLE IF EXISTS tmp_dup_groups;
|
||||
CREATE TEMPORARY TABLE tmp_dup_groups AS
|
||||
SELECT uid, wa_selfbonus_logid, link_id, MIN(id) AS keep_id
|
||||
FROM eb_user_integral_record
|
||||
WHERE link_type = 'selfbonus'
|
||||
AND type = 1
|
||||
AND wa_selfbonus_logid IS NOT NULL
|
||||
GROUP BY uid, wa_selfbonus_logid, link_id
|
||||
HAVING COUNT(*) > 1;
|
||||
|
||||
DROP TEMPORARY TABLE IF EXISTS tmp_affected_uids;
|
||||
CREATE TEMPORARY TABLE tmp_affected_uids AS
|
||||
SELECT DISTINCT uid FROM tmp_dup_groups;
|
||||
|
||||
DELETE e
|
||||
FROM eb_user_integral_record e
|
||||
JOIN tmp_dup_groups d
|
||||
ON d.uid = e.uid
|
||||
AND d.wa_selfbonus_logid = e.wa_selfbonus_logid
|
||||
AND d.link_id = e.link_id
|
||||
WHERE e.id <> d.keep_id;
|
||||
|
||||
UPDATE eb_user u
|
||||
JOIN (
|
||||
SELECT r.uid, COALESCE(SUM(r.integral), 0) AS sum_integral
|
||||
FROM eb_user_integral_record r
|
||||
JOIN tmp_affected_uids t ON t.uid = r.uid
|
||||
GROUP BY r.uid
|
||||
) s ON s.uid = u.uid
|
||||
SET u.integral = s.sum_integral;
|
||||
|
||||
SET @run_uid := 0;
|
||||
SET @run_bal := 0;
|
||||
UPDATE eb_user_integral_record e
|
||||
JOIN (
|
||||
SELECT t.id, FLOOR(t.running) AS new_balance
|
||||
FROM (
|
||||
SELECT s.id,
|
||||
s.uid,
|
||||
(@run_bal := IF(@run_uid = s.uid, @run_bal + s.integral, s.integral)) AS running,
|
||||
(@run_uid := s.uid) AS uid_guard
|
||||
FROM (
|
||||
SELECT id, uid, integral
|
||||
FROM eb_user_integral_record
|
||||
WHERE uid IN (SELECT uid FROM tmp_affected_uids)
|
||||
ORDER BY uid, id
|
||||
) s
|
||||
) t
|
||||
) x ON x.id = e.id
|
||||
SET e.balance = x.new_balance;
|
||||
|
||||
COMMIT;
|
||||
|
||||
-- 3) Post-checks
|
||||
SELECT COUNT(*) AS remaining_dup_groups
|
||||
FROM (
|
||||
SELECT 1
|
||||
FROM eb_user_integral_record
|
||||
WHERE link_type = 'selfbonus'
|
||||
AND type = 1
|
||||
AND wa_selfbonus_logid IS NOT NULL
|
||||
GROUP BY uid, wa_selfbonus_logid, link_id
|
||||
HAVING COUNT(*) > 1
|
||||
) a;
|
||||
|
||||
SELECT u.uid, u.integral, s.sum_integral
|
||||
FROM eb_user u
|
||||
JOIN (
|
||||
SELECT uid, SUM(integral) AS sum_integral
|
||||
FROM eb_user_integral_record
|
||||
GROUP BY uid
|
||||
) s ON s.uid = u.uid
|
||||
WHERE u.uid IN (SELECT DISTINCT uid FROM backup_euir_selfbonus_dups_20260511_0959)
|
||||
ORDER BY u.uid;
|
||||
5
dashboard-frontend/.env.development
Normal file
5
dashboard-frontend/.env.development
Normal file
@@ -0,0 +1,5 @@
|
||||
VITE_APP_ENV=development
|
||||
VITE_API_BASE_URL=/api/admin
|
||||
VITE_MOCK_ENABLED=false
|
||||
VITE_APP_TITLE=经营驾驶舱
|
||||
VITE_BUILD_VERSION=local
|
||||
24
dashboard-frontend/.gitignore
vendored
Normal file
24
dashboard-frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
122
dashboard-frontend/README.md
Normal file
122
dashboard-frontend/README.md
Normal file
@@ -0,0 +1,122 @@
|
||||
# Dashboard Frontend
|
||||
|
||||
独立 H5 经营驾驶舱前端项目。第一阶段只使用本地 Mock 数据,不对接后端 API。
|
||||
|
||||
## 技术栈
|
||||
|
||||
- React 19
|
||||
- TypeScript
|
||||
- Vite
|
||||
- antd-mobile
|
||||
- TanStack Query
|
||||
- Zustand
|
||||
- Axios
|
||||
- ECharts
|
||||
- MSW
|
||||
- Vitest
|
||||
|
||||
## 本地开发
|
||||
|
||||
```bash
|
||||
nvm use --delete-prefix v24.14.1
|
||||
pnpm install
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
默认访问:
|
||||
|
||||
```text
|
||||
http://localhost:5174/h5/dashboard/boss
|
||||
```
|
||||
|
||||
## 第一阶段范围
|
||||
|
||||
- H5 移动端老板驾驶舱首页
|
||||
- 昨日经营核心 KPI
|
||||
- 今日 10:15 / 14:55 节点快报 Mock
|
||||
- 近 7 天交易趋势
|
||||
- 用户、团队、商品排行
|
||||
- 风险预警摘要
|
||||
- 底部 Tab 导航
|
||||
- MSW Mock 数据
|
||||
|
||||
## 校验
|
||||
|
||||
```bash
|
||||
pnpm typecheck
|
||||
pnpm test -- --run
|
||||
pnpm build
|
||||
```
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
||||
|
||||
## React Compiler
|
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||
|
||||
```js
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
|
||||
// Remove tseslint.configs.recommended and replace with this
|
||||
tseslint.configs.recommendedTypeChecked,
|
||||
// Alternatively, use this for stricter rules
|
||||
tseslint.configs.strictTypeChecked,
|
||||
// Optionally, add this for stylistic rules
|
||||
tseslint.configs.stylisticTypeChecked,
|
||||
|
||||
// Other configs...
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
|
||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import reactX from 'eslint-plugin-react-x'
|
||||
import reactDom from 'eslint-plugin-react-dom'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
// Enable lint rules for React
|
||||
reactX.configs['recommended-typescript'],
|
||||
// Enable lint rules for React DOM
|
||||
reactDom.configs.recommended,
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
22
dashboard-frontend/eslint.config.js
Normal file
22
dashboard-frontend/eslint.config.js
Normal file
@@ -0,0 +1,22 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist', 'public/mockServiceWorker.js']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
])
|
||||
13
dashboard-frontend/index.html
Normal file
13
dashboard-frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>dashboard-frontend</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
50
dashboard-frontend/package.json
Normal file
50
dashboard-frontend/package.json
Normal file
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"name": "dashboard-frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc -b --noEmit",
|
||||
"test": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.100.9",
|
||||
"antd-mobile": "^5.42.3",
|
||||
"antd-mobile-icons": "^0.3.0",
|
||||
"axios": "^1.16.0",
|
||||
"echarts": "^6.0.0",
|
||||
"msw": "^2.14.5",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
"react-router-dom": "^7.15.0",
|
||||
"zustand": "^5.0.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/node": "^24.12.2",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"eslint": "^10.2.1",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.5.0",
|
||||
"jsdom": "^29.1.1",
|
||||
"typescript": "~6.0.2",
|
||||
"typescript-eslint": "^8.58.2",
|
||||
"vite": "^8.0.10",
|
||||
"vitest": "^4.1.5"
|
||||
},
|
||||
"msw": {
|
||||
"workerDirectory": [
|
||||
"public"
|
||||
]
|
||||
}
|
||||
}
|
||||
3461
dashboard-frontend/pnpm-lock.yaml
generated
Normal file
3461
dashboard-frontend/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
1
dashboard-frontend/public/favicon.svg
Normal file
1
dashboard-frontend/public/favicon.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
24
dashboard-frontend/public/icons.svg
Normal file
24
dashboard-frontend/public/icons.svg
Normal file
@@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
349
dashboard-frontend/public/mockServiceWorker.js
Normal file
349
dashboard-frontend/public/mockServiceWorker.js
Normal file
@@ -0,0 +1,349 @@
|
||||
/* eslint-disable */
|
||||
/* tslint:disable */
|
||||
|
||||
/**
|
||||
* Mock Service Worker.
|
||||
* @see https://github.com/mswjs/msw
|
||||
* - Please do NOT modify this file.
|
||||
*/
|
||||
|
||||
const PACKAGE_VERSION = '2.14.5'
|
||||
const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82'
|
||||
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
|
||||
const activeClientIds = new Set()
|
||||
|
||||
addEventListener('install', function () {
|
||||
self.skipWaiting()
|
||||
})
|
||||
|
||||
addEventListener('activate', function (event) {
|
||||
event.waitUntil(self.clients.claim())
|
||||
})
|
||||
|
||||
addEventListener('message', async function (event) {
|
||||
const clientId = Reflect.get(event.source || {}, 'id')
|
||||
|
||||
if (!clientId || !self.clients) {
|
||||
return
|
||||
}
|
||||
|
||||
const client = await self.clients.get(clientId)
|
||||
|
||||
if (!client) {
|
||||
return
|
||||
}
|
||||
|
||||
const allClients = await self.clients.matchAll({
|
||||
type: 'window',
|
||||
})
|
||||
|
||||
switch (event.data) {
|
||||
case 'KEEPALIVE_REQUEST': {
|
||||
sendToClient(client, {
|
||||
type: 'KEEPALIVE_RESPONSE',
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'INTEGRITY_CHECK_REQUEST': {
|
||||
sendToClient(client, {
|
||||
type: 'INTEGRITY_CHECK_RESPONSE',
|
||||
payload: {
|
||||
packageVersion: PACKAGE_VERSION,
|
||||
checksum: INTEGRITY_CHECKSUM,
|
||||
},
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'MOCK_ACTIVATE': {
|
||||
activeClientIds.add(clientId)
|
||||
|
||||
sendToClient(client, {
|
||||
type: 'MOCKING_ENABLED',
|
||||
payload: {
|
||||
client: {
|
||||
id: client.id,
|
||||
frameType: client.frameType,
|
||||
},
|
||||
},
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'CLIENT_CLOSED': {
|
||||
activeClientIds.delete(clientId)
|
||||
|
||||
const remainingClients = allClients.filter((client) => {
|
||||
return client.id !== clientId
|
||||
})
|
||||
|
||||
// Unregister itself when there are no more clients
|
||||
if (remainingClients.length === 0) {
|
||||
self.registration.unregister()
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
addEventListener('fetch', function (event) {
|
||||
const requestInterceptedAt = Date.now()
|
||||
|
||||
// Bypass navigation requests.
|
||||
if (event.request.mode === 'navigate') {
|
||||
return
|
||||
}
|
||||
|
||||
// Opening the DevTools triggers the "only-if-cached" request
|
||||
// that cannot be handled by the worker. Bypass such requests.
|
||||
if (
|
||||
event.request.cache === 'only-if-cached' &&
|
||||
event.request.mode !== 'same-origin'
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
// Bypass all requests when there are no active clients.
|
||||
// Prevents the self-unregistered worked from handling requests
|
||||
// after it's been terminated (still remains active until the next reload).
|
||||
if (activeClientIds.size === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const requestId = crypto.randomUUID()
|
||||
event.respondWith(handleRequest(event, requestId, requestInterceptedAt))
|
||||
})
|
||||
|
||||
/**
|
||||
* @param {FetchEvent} event
|
||||
* @param {string} requestId
|
||||
* @param {number} requestInterceptedAt
|
||||
*/
|
||||
async function handleRequest(event, requestId, requestInterceptedAt) {
|
||||
const client = await resolveMainClient(event)
|
||||
const requestCloneForEvents = event.request.clone()
|
||||
const response = await getResponse(
|
||||
event,
|
||||
client,
|
||||
requestId,
|
||||
requestInterceptedAt,
|
||||
)
|
||||
|
||||
// Send back the response clone for the "response:*" life-cycle events.
|
||||
// Ensure MSW is active and ready to handle the message, otherwise
|
||||
// this message will pend indefinitely.
|
||||
if (client && activeClientIds.has(client.id)) {
|
||||
const serializedRequest = await serializeRequest(requestCloneForEvents)
|
||||
|
||||
// Clone the response so both the client and the library could consume it.
|
||||
const responseClone = response.clone()
|
||||
|
||||
sendToClient(
|
||||
client,
|
||||
{
|
||||
type: 'RESPONSE',
|
||||
payload: {
|
||||
isMockedResponse: IS_MOCKED_RESPONSE in response,
|
||||
request: {
|
||||
id: requestId,
|
||||
...serializedRequest,
|
||||
},
|
||||
response: {
|
||||
type: responseClone.type,
|
||||
status: responseClone.status,
|
||||
statusText: responseClone.statusText,
|
||||
headers: Object.fromEntries(responseClone.headers.entries()),
|
||||
body: responseClone.body,
|
||||
},
|
||||
},
|
||||
},
|
||||
responseClone.body ? [serializedRequest.body, responseClone.body] : [],
|
||||
)
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the main client for the given event.
|
||||
* Client that issues a request doesn't necessarily equal the client
|
||||
* that registered the worker. It's with the latter the worker should
|
||||
* communicate with during the response resolving phase.
|
||||
* @param {FetchEvent} event
|
||||
* @returns {Promise<Client | undefined>}
|
||||
*/
|
||||
async function resolveMainClient(event) {
|
||||
const client = await self.clients.get(event.clientId)
|
||||
|
||||
if (activeClientIds.has(event.clientId)) {
|
||||
return client
|
||||
}
|
||||
|
||||
if (client?.frameType === 'top-level') {
|
||||
return client
|
||||
}
|
||||
|
||||
const allClients = await self.clients.matchAll({
|
||||
type: 'window',
|
||||
})
|
||||
|
||||
return allClients
|
||||
.filter((client) => {
|
||||
// Get only those clients that are currently visible.
|
||||
return client.visibilityState === 'visible'
|
||||
})
|
||||
.find((client) => {
|
||||
// Find the client ID that's recorded in the
|
||||
// set of clients that have registered the worker.
|
||||
return activeClientIds.has(client.id)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {FetchEvent} event
|
||||
* @param {Client | undefined} client
|
||||
* @param {string} requestId
|
||||
* @param {number} requestInterceptedAt
|
||||
* @returns {Promise<Response>}
|
||||
*/
|
||||
async function getResponse(event, client, requestId, requestInterceptedAt) {
|
||||
// Clone the request because it might've been already used
|
||||
// (i.e. its body has been read and sent to the client).
|
||||
const requestClone = event.request.clone()
|
||||
|
||||
function passthrough() {
|
||||
// Cast the request headers to a new Headers instance
|
||||
// so the headers can be manipulated with.
|
||||
const headers = new Headers(requestClone.headers)
|
||||
|
||||
// Remove the "accept" header value that marked this request as passthrough.
|
||||
// This prevents request alteration and also keeps it compliant with the
|
||||
// user-defined CORS policies.
|
||||
const acceptHeader = headers.get('accept')
|
||||
if (acceptHeader) {
|
||||
const values = acceptHeader.split(',').map((value) => value.trim())
|
||||
const filteredValues = values.filter(
|
||||
(value) => value !== 'msw/passthrough',
|
||||
)
|
||||
|
||||
if (filteredValues.length > 0) {
|
||||
headers.set('accept', filteredValues.join(', '))
|
||||
} else {
|
||||
headers.delete('accept')
|
||||
}
|
||||
}
|
||||
|
||||
return fetch(requestClone, { headers })
|
||||
}
|
||||
|
||||
// Bypass mocking when the client is not active.
|
||||
if (!client) {
|
||||
return passthrough()
|
||||
}
|
||||
|
||||
// Bypass initial page load requests (i.e. static assets).
|
||||
// The absence of the immediate/parent client in the map of the active clients
|
||||
// means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet
|
||||
// and is not ready to handle requests.
|
||||
if (!activeClientIds.has(client.id)) {
|
||||
return passthrough()
|
||||
}
|
||||
|
||||
// Notify the client that a request has been intercepted.
|
||||
const serializedRequest = await serializeRequest(event.request)
|
||||
const clientMessage = await sendToClient(
|
||||
client,
|
||||
{
|
||||
type: 'REQUEST',
|
||||
payload: {
|
||||
id: requestId,
|
||||
interceptedAt: requestInterceptedAt,
|
||||
...serializedRequest,
|
||||
},
|
||||
},
|
||||
[serializedRequest.body],
|
||||
)
|
||||
|
||||
switch (clientMessage.type) {
|
||||
case 'MOCK_RESPONSE': {
|
||||
return respondWithMock(clientMessage.data)
|
||||
}
|
||||
|
||||
case 'PASSTHROUGH': {
|
||||
return passthrough()
|
||||
}
|
||||
}
|
||||
|
||||
return passthrough()
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Client} client
|
||||
* @param {any} message
|
||||
* @param {Array<Transferable>} transferrables
|
||||
* @returns {Promise<any>}
|
||||
*/
|
||||
function sendToClient(client, message, transferrables = []) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const channel = new MessageChannel()
|
||||
|
||||
channel.port1.onmessage = (event) => {
|
||||
if (event.data && event.data.error) {
|
||||
return reject(event.data.error)
|
||||
}
|
||||
|
||||
resolve(event.data)
|
||||
}
|
||||
|
||||
client.postMessage(message, [
|
||||
channel.port2,
|
||||
...transferrables.filter(Boolean),
|
||||
])
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Response} response
|
||||
* @returns {Response}
|
||||
*/
|
||||
function respondWithMock(response) {
|
||||
// Setting response status code to 0 is a no-op.
|
||||
// However, when responding with a "Response.error()", the produced Response
|
||||
// instance will have status code set to 0. Since it's not possible to create
|
||||
// a Response instance with status code 0, handle that use-case separately.
|
||||
if (response.status === 0) {
|
||||
return Response.error()
|
||||
}
|
||||
|
||||
const mockedResponse = new Response(response.body, response)
|
||||
|
||||
Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, {
|
||||
value: true,
|
||||
enumerable: true,
|
||||
})
|
||||
|
||||
return mockedResponse
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Request} request
|
||||
*/
|
||||
async function serializeRequest(request) {
|
||||
return {
|
||||
url: request.url,
|
||||
mode: request.mode,
|
||||
method: request.method,
|
||||
headers: Object.fromEntries(request.headers.entries()),
|
||||
cache: request.cache,
|
||||
credentials: request.credentials,
|
||||
destination: request.destination,
|
||||
integrity: request.integrity,
|
||||
redirect: request.redirect,
|
||||
referrer: request.referrer,
|
||||
referrerPolicy: request.referrerPolicy,
|
||||
body: await request.arrayBuffer(),
|
||||
keepalive: request.keepalive,
|
||||
}
|
||||
}
|
||||
27
dashboard-frontend/src/App.tsx
Normal file
27
dashboard-frontend/src/App.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'
|
||||
import { AppProviders } from './app/providers/AppProviders'
|
||||
import { MobileLayout } from './app/layouts/MobileLayout'
|
||||
import { BossDashboardPage } from './features/boss-dashboard/pages/BossDashboardPage'
|
||||
import { DailyReportPage, ProfilePage, RiskCenterPage, TodaySnapshotPage } from './features/boss-dashboard/pages/OperationsPages'
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<AppProviders>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to="/h5/dashboard/boss" replace />} />
|
||||
<Route element={<MobileLayout />}>
|
||||
<Route path="/h5/dashboard/boss" element={<BossDashboardPage />} />
|
||||
<Route path="/h5/dashboard/daily-report" element={<DailyReportPage />} />
|
||||
<Route path="/h5/dashboard/today-snapshot" element={<TodaySnapshotPage />} />
|
||||
<Route path="/h5/dashboard/risk-center" element={<RiskCenterPage />} />
|
||||
<Route path="/h5/dashboard/profile" element={<ProfilePage />} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/h5/dashboard/boss" replace />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</AppProviders>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
34
dashboard-frontend/src/app/layouts/MobileLayout.tsx
Normal file
34
dashboard-frontend/src/app/layouts/MobileLayout.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { AppOutline, BellOutline, FileOutline, HistogramOutline, UserOutline } from 'antd-mobile-icons'
|
||||
import { Outlet, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { SafeArea, TabBar } from 'antd-mobile'
|
||||
|
||||
const tabs = [
|
||||
{ key: '/h5/dashboard/boss', title: '首页', icon: <AppOutline /> },
|
||||
{ key: '/h5/dashboard/daily-report', title: '日报', icon: <FileOutline /> },
|
||||
{ key: '/h5/dashboard/today-snapshot', title: '快报', icon: <HistogramOutline /> },
|
||||
{ key: '/h5/dashboard/risk-center', title: '风险', icon: <BellOutline /> },
|
||||
{ key: '/h5/dashboard/profile', title: '我的', icon: <UserOutline /> },
|
||||
]
|
||||
|
||||
export function MobileLayout() {
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const activeKey = tabs.find((tab) => location.pathname.startsWith(tab.key))?.key ?? tabs[0].key
|
||||
|
||||
return (
|
||||
<div className="mobile-shell">
|
||||
<main className="mobile-main">
|
||||
<Outlet />
|
||||
</main>
|
||||
<nav className="bottom-nav" aria-label="Dashboard mobile navigation">
|
||||
<TabBar activeKey={activeKey} onChange={(key) => navigate(key)}>
|
||||
{tabs.map((tab) => (
|
||||
<TabBar.Item key={tab.key} icon={tab.icon} title={tab.title} />
|
||||
))}
|
||||
</TabBar>
|
||||
<SafeArea position="bottom" />
|
||||
</nav>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
26
dashboard-frontend/src/app/providers/AppProviders.tsx
Normal file
26
dashboard-frontend/src/app/providers/AppProviders.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { ConfigProvider } from 'antd-mobile'
|
||||
import zhCN from 'antd-mobile/es/locales/zh-CN'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 60 * 1000,
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
type AppProvidersProps = {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function AppProviders({ children }: AppProvidersProps) {
|
||||
return (
|
||||
<ConfigProvider locale={zhCN}>
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
</ConfigProvider>
|
||||
)
|
||||
}
|
||||
76
dashboard-frontend/src/components/charts/MiniTrendChart.tsx
Normal file
76
dashboard-frontend/src/components/charts/MiniTrendChart.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import * as echarts from 'echarts/core'
|
||||
import { GridComponent, TooltipComponent } from 'echarts/components'
|
||||
import { BarChart, LineChart } from 'echarts/charts'
|
||||
import { CanvasRenderer } from 'echarts/renderers'
|
||||
import { useEffect, useMemo, useRef } from 'react'
|
||||
import type { TrendPoint } from '../../features/boss-dashboard/types'
|
||||
|
||||
echarts.use([GridComponent, TooltipComponent, LineChart, BarChart, CanvasRenderer])
|
||||
|
||||
type MiniTrendChartProps = {
|
||||
data: TrendPoint[]
|
||||
}
|
||||
|
||||
export function MiniTrendChart({ data }: MiniTrendChartProps) {
|
||||
const chartRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
const option = useMemo(
|
||||
() => ({
|
||||
color: ['#ff5b36', '#ffb000'],
|
||||
grid: { left: 8, right: 8, top: 24, bottom: 18, containLabel: true },
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
confine: true,
|
||||
valueFormatter: (value: number | string) => Number(value).toLocaleString('zh-CN'),
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: data.map((item) => item.date),
|
||||
axisLine: { show: false },
|
||||
axisTick: { show: false },
|
||||
},
|
||||
yAxis: [
|
||||
{ type: 'value', show: false },
|
||||
{ type: 'value', show: false },
|
||||
],
|
||||
series: [
|
||||
{
|
||||
name: '成交额',
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
yAxisIndex: 0,
|
||||
data: data.map((item) => item.amount),
|
||||
symbol: 'circle',
|
||||
symbolSize: 5,
|
||||
lineStyle: { width: 3 },
|
||||
areaStyle: { opacity: 0.08 },
|
||||
},
|
||||
{
|
||||
name: '订单数',
|
||||
type: 'bar',
|
||||
yAxisIndex: 1,
|
||||
data: data.map((item) => item.orders),
|
||||
barWidth: 8,
|
||||
borderRadius: 6,
|
||||
},
|
||||
],
|
||||
}),
|
||||
[data],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!chartRef.current) return undefined
|
||||
const chart = echarts.init(chartRef.current)
|
||||
chart.setOption(option)
|
||||
|
||||
const resize = () => chart.resize()
|
||||
window.addEventListener('resize', resize)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', resize)
|
||||
chart.dispose()
|
||||
}
|
||||
}, [option])
|
||||
|
||||
return <div className="mini-trend-chart" ref={chartRef} aria-label="近 7 天交易趋势图" />
|
||||
}
|
||||
32
dashboard-frontend/src/components/kpi/KpiCard.tsx
Normal file
32
dashboard-frontend/src/components/kpi/KpiCard.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Skeleton } from 'antd-mobile'
|
||||
import { formatMetricValue, formatTrend } from '../../utils/format'
|
||||
import type { KpiMetric } from '../../features/boss-dashboard/types'
|
||||
|
||||
type KpiCardProps = {
|
||||
metric: KpiMetric
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
export function KpiCard({ metric, loading }: KpiCardProps) {
|
||||
if (loading) {
|
||||
return (
|
||||
<article className="kpi-card">
|
||||
<Skeleton.Title animated />
|
||||
<Skeleton.Paragraph lineCount={1} animated />
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<article className={`kpi-card kpi-card--${metric.status} ${metric.featured ? 'kpi-card--featured' : ''}`}>
|
||||
<p className="kpi-title">{metric.title}</p>
|
||||
<strong className="kpi-value">{formatMetricValue(metric.value, metric.unit)}</strong>
|
||||
{(metric.trendLabel || metric.trendValue !== undefined) && (
|
||||
<p className="kpi-trend">
|
||||
{metric.trendLabel}
|
||||
{metric.trendValue !== undefined && <span>{formatTrend(metric.trendValue)}</span>}
|
||||
</p>
|
||||
)}
|
||||
</article>
|
||||
)
|
||||
}
|
||||
18
dashboard-frontend/src/features/boss-dashboard/api.ts
Normal file
18
dashboard-frontend/src/features/boss-dashboard/api.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { getApiData, getBlob } from '../../services/http/client'
|
||||
import type { DashboardOverview } from './types'
|
||||
|
||||
export const dashboardQueryKeys = {
|
||||
overview: (date?: string) => ['dashboard', 'overview', date ?? 'default'] as const,
|
||||
}
|
||||
|
||||
export function useDashboardOverview(date?: string) {
|
||||
return useQuery({
|
||||
queryKey: dashboardQueryKeys.overview(date),
|
||||
queryFn: () => getApiData<DashboardOverview>(date ? `/dashboard/overview?date=${date}` : '/dashboard/overview'),
|
||||
})
|
||||
}
|
||||
|
||||
export function downloadDailyReportArchive(date?: string) {
|
||||
return getBlob(date ? `/dashboard/daily-report/archive?date=${date}` : '/dashboard/daily-report/archive')
|
||||
}
|
||||
215
dashboard-frontend/src/features/boss-dashboard/archive.ts
Normal file
215
dashboard-frontend/src/features/boss-dashboard/archive.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
import { formatMetricValue, formatMoney, formatNumber } from '../../utils/format'
|
||||
import type { DashboardOverview, MetricStatus, RankItem, RiskAlert, RiskLevel, SnapshotSlot, TodaySnapshot } from './types'
|
||||
|
||||
const snapshotTitle: Record<SnapshotSlot, string> = {
|
||||
'1015': '上午抢购快报',
|
||||
'1455': '下午寄卖/转卖快报',
|
||||
}
|
||||
|
||||
const snapshotDescription: Record<SnapshotSlot, string> = {
|
||||
'1015': '用户集中抢购上一天用户寄卖的商品,重点看成交、付款和采购用户是否达标。',
|
||||
'1455': '用户把上午抢到的商品继续寄卖或转卖,重点看新增寄售供给和奖金变化是否正常。',
|
||||
}
|
||||
|
||||
const metricStatusText: Record<MetricStatus, string> = {
|
||||
normal: '正常',
|
||||
success: '达标',
|
||||
warning: '关注',
|
||||
danger: '异常',
|
||||
}
|
||||
|
||||
const riskLevelText: Record<RiskLevel, string> = {
|
||||
red: '红色',
|
||||
yellow: '黄色',
|
||||
gray: '灰色',
|
||||
}
|
||||
|
||||
function escapeHtml(value: unknown): string {
|
||||
return String(value ?? '')
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''')
|
||||
}
|
||||
|
||||
function serializeStaticData(data: DashboardOverview): string {
|
||||
return JSON.stringify(data, null, 2).replaceAll('<', '\\u003c').replaceAll('>', '\\u003e')
|
||||
}
|
||||
|
||||
function renderMetricGrid(metrics: DashboardOverview['kpis']): string {
|
||||
return metrics
|
||||
.map(
|
||||
(metric) => `
|
||||
<article class="card metric-card">
|
||||
<span>${escapeHtml(metricStatusText[metric.status])}</span>
|
||||
<h3>${escapeHtml(metric.title)}</h3>
|
||||
<strong>${escapeHtml(formatMetricValue(metric.value, metric.unit))}</strong>
|
||||
${metric.trendLabel ? `<p>${escapeHtml(metric.trendLabel)} ${escapeHtml(metric.trendValue ?? '')}%</p>` : ''}
|
||||
</article>`,
|
||||
)
|
||||
.join('')
|
||||
}
|
||||
|
||||
function renderSnapshots(snapshots: TodaySnapshot[]): string {
|
||||
return snapshots
|
||||
.map((snapshot) => {
|
||||
const bonusChange = Number(snapshot.selfBonusChange) + Number(snapshot.shareBonusChange)
|
||||
return `
|
||||
<article class="card snapshot-card">
|
||||
<div class="card-title-row">
|
||||
<div>
|
||||
<span>${escapeHtml(snapshot.slot)}</span>
|
||||
<h3>${escapeHtml(snapshotTitle[snapshot.slot])}</h3>
|
||||
</div>
|
||||
<mark>${escapeHtml(snapshot.status)}</mark>
|
||||
</div>
|
||||
<p>${escapeHtml(snapshotDescription[snapshot.slot])}</p>
|
||||
<p class="message">${escapeHtml(snapshot.message)}</p>
|
||||
${snapshot.generatedAt ? `<small>生成时间:${escapeHtml(snapshot.generatedAt)}</small>` : ''}
|
||||
<div class="snapshot-grid">
|
||||
<span>用户<strong>${escapeHtml(formatNumber(snapshot.purchaseUsers))}人</strong></span>
|
||||
<span>订单<strong>${escapeHtml(formatNumber(snapshot.orderCount))}单</strong></span>
|
||||
<span>成交额<strong>${escapeHtml(formatMoney(snapshot.dealAmount))}</strong></span>
|
||||
<span>已支付<strong>${escapeHtml(formatMoney(snapshot.paidAmount))}</strong></span>
|
||||
<span>商品<strong>${escapeHtml(formatNumber(snapshot.newMerchandiseCount))}件</strong></span>
|
||||
<span>奖金<strong>${escapeHtml(formatMoney(bonusChange))}</strong></span>
|
||||
</div>
|
||||
</article>`
|
||||
})
|
||||
.join('')
|
||||
}
|
||||
|
||||
function renderRanks(title: string, ranks: RankItem[]): string {
|
||||
return `
|
||||
<section class="section">
|
||||
<h2>${escapeHtml(title)}</h2>
|
||||
<div class="rank-list">
|
||||
${ranks
|
||||
.map(
|
||||
(rank, index) => `
|
||||
<div class="rank-item">
|
||||
<b>${index + 1}</b>
|
||||
<span>
|
||||
<strong>${escapeHtml(rank.name)}</strong>
|
||||
<small>${escapeHtml(rank.description)}</small>
|
||||
</span>
|
||||
<em>${escapeHtml(formatMoney(rank.value))}</em>
|
||||
</div>`,
|
||||
)
|
||||
.join('')}
|
||||
</div>
|
||||
</section>`
|
||||
}
|
||||
|
||||
function renderRisks(risks: RiskAlert[]): string {
|
||||
return risks
|
||||
.map(
|
||||
(risk) => `
|
||||
<article class="risk risk--${risk.level}">
|
||||
<div>
|
||||
<mark>${escapeHtml(riskLevelText[risk.level])}</mark>
|
||||
<span>${escapeHtml(risk.type)}</span>
|
||||
<time>${escapeHtml(risk.discoveredAt)}</time>
|
||||
</div>
|
||||
<strong>${escapeHtml(risk.title)}</strong>
|
||||
<p>${escapeHtml(risk.description)}</p>
|
||||
</article>`,
|
||||
)
|
||||
.join('')
|
||||
}
|
||||
|
||||
export function buildDailyReportArchiveHtml(data: DashboardOverview): string {
|
||||
const generatedAt = new Date().toLocaleString('zh-CN', { hour12: false })
|
||||
|
||||
return `<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>经营日报归档 - ${escapeHtml(data.businessDate)}</title>
|
||||
<style>
|
||||
:root { --bg: #fff6f1; --surface: #fff; --surface-soft: #f6f9fb; --text: #132033; --muted: #6b7a90; --border: rgba(19, 32, 51, .08); --primary: #ff5b36; --warning: #ffb000; --danger: #dc2626; --shadow: 0 16px 40px rgba(255, 91, 54, .14); --radius-xl: 28px; --radius-lg: 20px; --radius-md: 14px; }
|
||||
* { box-sizing: border-box; }
|
||||
body { min-width: 320px; margin: 0; color: var(--text); background: radial-gradient(circle at top left, rgba(255, 91, 54, .2), transparent 28rem), var(--bg); font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; -webkit-font-smoothing: antialiased; }
|
||||
main { width: min(100%, 430px); min-height: 100svh; margin: 0 auto; padding: 14px 14px 24px; background: var(--bg); box-shadow: 0 0 0 1px rgba(19, 32, 51, .04); }
|
||||
.hero { position: relative; overflow: hidden; padding: 20px; color: #fff; background: linear-gradient(145deg, rgba(255, 91, 54, .98), rgba(255, 139, 82, .92)), radial-gradient(circle at 90% 10%, rgba(255, 176, 0, .42), transparent 18rem); border-radius: 0 0 var(--radius-xl) var(--radius-xl); box-shadow: var(--shadow); }
|
||||
.hero p { margin: 0; color: rgba(255, 255, 255, .76); line-height: 1.6; }
|
||||
.eyebrow, .card-title-row span, .metric-card > span { margin: 0; color: rgba(255, 255, 255, .68); font-size: 12px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; }
|
||||
h1, h2, h3 { margin: 0; }
|
||||
h1 { margin: 12px 0 8px; font-size: 28px; line-height: 1.12; }
|
||||
h2 { font-size: 18px; margin-bottom: 12px; }
|
||||
h3 { font-size: 16px; }
|
||||
.meta { display: grid; gap: 8px; margin-top: 16px; }
|
||||
.meta span { display: inline-flex; width: max-content; padding: 7px 12px; color: rgba(255, 255, 255, .86); font-size: 12px; font-weight: 700; background: rgba(255, 255, 255, .14); border: 1px solid rgba(255, 255, 255, .18); border-radius: 999px; }
|
||||
.section { margin-top: 14px; padding: 16px; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-xl); box-shadow: 0 10px 28px rgba(22, 47, 80, .08); }
|
||||
.grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
|
||||
.card { padding: 14px; background: var(--surface-soft); border: 0; border-radius: var(--radius-lg); }
|
||||
.metric-card { min-height: 112px; background: var(--surface); border: 1px solid var(--border); box-shadow: 0 10px 28px rgba(22, 47, 80, .08); }
|
||||
.metric-card > span { color: var(--muted); }
|
||||
.metric-card strong { display: block; margin-top: 8px; color: var(--text); font-size: 22px; line-height: 1.08; word-break: break-all; }
|
||||
.metric-card p { margin: 8px 0 0; color: var(--muted); font-size: 12px; }
|
||||
.snapshot-stack, .trend-list, .rank-list, .risk-list { display: grid; gap: 10px; margin-top: 14px; }
|
||||
.card-title-row, .rank-item, .risk div { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
||||
.card-title-row span { color: var(--muted); }
|
||||
mark { padding: 3px 8px; color: var(--primary); font-size: 12px; font-weight: 700; background: #fff0eb; border: 0; border-radius: 999px; }
|
||||
.snapshot-card p { margin: 12px 0 0; color: var(--muted); font-size: 13px; line-height: 1.55; }
|
||||
.snapshot-card .message { margin: 10px 0 8px; color: var(--text); font-weight: 700; line-height: 1.55; }
|
||||
.snapshot-card small { color: var(--muted); }
|
||||
.snapshot-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; margin-top: 12px; }
|
||||
.snapshot-grid span, .trend-item, .rank-item, .risk { padding: 12px; background: var(--surface-soft); border: 0; border-radius: var(--radius-md); }
|
||||
.snapshot-grid span { color: var(--muted); font-size: 12px; }
|
||||
.snapshot-grid strong { display: block; margin-top: 4px; color: var(--text); font-size: 15px; }
|
||||
.trend-item { display: grid; gap: 4px; }
|
||||
.trend-item span { color: var(--muted); font-size: 13px; }
|
||||
.rank-item b { width: 28px; height: 28px; display: inline-grid; place-items: center; border-radius: 10px; color: #fff; background: var(--primary); }
|
||||
.rank-item span { flex: 1; }
|
||||
.rank-item small { display: block; margin-top: 3px; color: var(--muted); line-height: 1.45; }
|
||||
.rank-item em { color: var(--primary); font-size: 13px; font-style: normal; font-weight: 800; }
|
||||
.risk strong { display: block; margin-top: 10px; }
|
||||
.risk p { margin: 6px 0 0; color: var(--muted); line-height: 1.5; }
|
||||
.risk--red mark { color: #991b1b; background: #fee2e2; }
|
||||
.risk--yellow mark { color: #92400e; background: #fef3c7; }
|
||||
.risk--gray mark { color: #475569; background: #e2e8f0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<section class="hero">
|
||||
<p class="eyebrow">Daily Report Archive</p>
|
||||
<h1>经营日报归档 ${escapeHtml(data.businessDate)}</h1>
|
||||
<p>${escapeHtml(data.summary)}</p>
|
||||
<div class="meta">
|
||||
<span>业务日期:${escapeHtml(data.businessDate)}</span>
|
||||
<span>数据生成:${escapeHtml(data.generatedAt)}</span>
|
||||
<span>归档生成:${escapeHtml(generatedAt)}</span>
|
||||
</div>
|
||||
</section>
|
||||
<section class="section"><h2>核心指标</h2><div class="grid">${renderMetricGrid(data.kpis)}</div></section>
|
||||
<section class="section"><h2>资金池摘要</h2><div class="grid">${renderMetricGrid(data.fundPool)}</div></section>
|
||||
<section class="section"><h2>今日快报</h2><div class="snapshot-stack">${renderSnapshots(data.snapshots)}</div></section>
|
||||
<section class="section">
|
||||
<h2>最近趋势</h2>
|
||||
<div class="trend-list">
|
||||
${data.trends
|
||||
.map(
|
||||
(trend) => `
|
||||
<div class="trend-item">
|
||||
<strong>${escapeHtml(trend.date)}</strong>
|
||||
<span>成交 ${escapeHtml(formatMoney(trend.amount))}</span>
|
||||
<span>订单 ${escapeHtml(formatNumber(trend.orders))} 单</span>
|
||||
<span>奖金 ${escapeHtml(formatMoney(trend.bonus))}</span>
|
||||
</div>`,
|
||||
)
|
||||
.join('')}
|
||||
</div>
|
||||
</section>
|
||||
${renderRanks('高价值用户', data.userRanks)}
|
||||
${renderRanks('团队贡献排行', data.teamRanks)}
|
||||
${renderRanks('高货值未成交商品', data.productRanks)}
|
||||
<section class="section"><h2>风险预警</h2><div class="risk-list">${renderRisks(data.risks)}</div></section>
|
||||
<script id="dashboard-static-data" type="application/json">${serializeStaticData(data)}</script>
|
||||
</main>
|
||||
</body>
|
||||
</html>`
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { RightOutline } from 'antd-mobile-icons'
|
||||
import { formatMoney, formatNumber } from '../../../utils/format'
|
||||
import type { RankItem } from '../types'
|
||||
|
||||
type RankListProps = {
|
||||
title: string
|
||||
items: RankItem[]
|
||||
valueType?: 'money' | 'number'
|
||||
}
|
||||
|
||||
export function RankList({ title, items, valueType = 'money' }: RankListProps) {
|
||||
return (
|
||||
<section className="section-block">
|
||||
<div className="section-title-row">
|
||||
<div>
|
||||
<p className="section-kicker">Top 3</p>
|
||||
<h2>{title}</h2>
|
||||
</div>
|
||||
<button className="text-button" type="button">
|
||||
全部
|
||||
<RightOutline />
|
||||
</button>
|
||||
</div>
|
||||
<div className="rank-list">
|
||||
{items.map((item, index) => (
|
||||
<button className="rank-item" key={item.id} type="button">
|
||||
<span className="rank-index">{index + 1}</span>
|
||||
<span className="rank-content">
|
||||
<strong>{item.name}</strong>
|
||||
<small>{item.description}</small>
|
||||
</span>
|
||||
<span className="rank-value">{valueType === 'money' ? formatMoney(item.value) : formatNumber(item.value)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Tag } from 'antd-mobile'
|
||||
import type { RiskAlert, RiskLevel } from '../types'
|
||||
|
||||
type RiskAlertSectionProps = {
|
||||
risks: RiskAlert[]
|
||||
}
|
||||
|
||||
const levelMeta: Record<RiskLevel, { color: 'danger' | 'warning' | 'default'; label: string }> = {
|
||||
red: { color: 'danger', label: '红色' },
|
||||
yellow: { color: 'warning', label: '黄色' },
|
||||
gray: { color: 'default', label: '灰色' },
|
||||
}
|
||||
|
||||
export function RiskAlertSection({ risks }: RiskAlertSectionProps) {
|
||||
return (
|
||||
<section className="section-block">
|
||||
<div className="section-title-row">
|
||||
<div>
|
||||
<p className="section-kicker">Risk</p>
|
||||
<h2>风险预警</h2>
|
||||
</div>
|
||||
<span className="risk-count">{risks.length} 条</span>
|
||||
</div>
|
||||
<div className="risk-list">
|
||||
{risks.map((risk) => {
|
||||
const meta = levelMeta[risk.level]
|
||||
return (
|
||||
<button className="risk-item" key={risk.id} type="button">
|
||||
<div className="risk-header">
|
||||
<Tag color={meta.color}>{meta.label}</Tag>
|
||||
<span>{risk.type}</span>
|
||||
<time>{risk.discoveredAt}</time>
|
||||
</div>
|
||||
<strong>{risk.title}</strong>
|
||||
<p>{risk.description}</p>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { CapsuleTabs, Tag } from 'antd-mobile'
|
||||
import { useState } from 'react'
|
||||
import { formatMoney, formatNumber } from '../../../utils/format'
|
||||
import type { SnapshotSlot, TodaySnapshot } from '../types'
|
||||
|
||||
type TodaySnapshotSectionProps = {
|
||||
snapshots: TodaySnapshot[]
|
||||
}
|
||||
|
||||
const statusMap = {
|
||||
pending: { color: 'default', label: '待生成' },
|
||||
success: { color: 'success', label: '已生成' },
|
||||
failed: { color: 'danger', label: '生成失败' },
|
||||
temporary: { color: 'warning', label: '临时数据' },
|
||||
} as const
|
||||
|
||||
export function TodaySnapshotSection({ snapshots }: TodaySnapshotSectionProps) {
|
||||
const [activeSlot, setActiveSlot] = useState<SnapshotSlot>('1015')
|
||||
const activeSnapshot = snapshots.find((snapshot) => snapshot.slot === activeSlot) ?? snapshots[0]
|
||||
const status = statusMap[activeSnapshot.status]
|
||||
|
||||
return (
|
||||
<section className="section-block snapshot-section">
|
||||
<div className="section-title-row">
|
||||
<div>
|
||||
<p className="section-kicker">今日节点</p>
|
||||
<h2>抢购 / 寄卖快报</h2>
|
||||
</div>
|
||||
<Tag color={status.color}>{status.label}</Tag>
|
||||
</div>
|
||||
|
||||
<CapsuleTabs activeKey={activeSlot} onChange={(key) => setActiveSlot(key as SnapshotSlot)}>
|
||||
{snapshots.map((snapshot) => (
|
||||
<CapsuleTabs.Tab title={snapshot.title.replace(' ', '')} key={snapshot.slot} />
|
||||
))}
|
||||
</CapsuleTabs>
|
||||
|
||||
<div className="snapshot-card">
|
||||
<p className="snapshot-message">{activeSnapshot.message}</p>
|
||||
{activeSnapshot.generatedAt && <p className="snapshot-time">生成时间:{activeSnapshot.generatedAt}</p>}
|
||||
<div className="snapshot-grid">
|
||||
<span>
|
||||
采购用户<strong>{formatNumber(activeSnapshot.purchaseUsers)}人</strong>
|
||||
</span>
|
||||
<span>
|
||||
订单数<strong>{formatNumber(activeSnapshot.orderCount)}单</strong>
|
||||
</span>
|
||||
<span>
|
||||
成交额<strong>{formatMoney(activeSnapshot.dealAmount)}</strong>
|
||||
</span>
|
||||
<span>
|
||||
支付额<strong>{formatMoney(activeSnapshot.paidAmount)}</strong>
|
||||
</span>
|
||||
<span>
|
||||
新增商品<strong>{formatNumber(activeSnapshot.newMerchandiseCount)}件</strong>
|
||||
</span>
|
||||
<span>
|
||||
奖金变化<strong>{formatMoney(Number(activeSnapshot.selfBonusChange) + Number(activeSnapshot.shareBonusChange))}</strong>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
104
dashboard-frontend/src/features/boss-dashboard/mock.ts
Normal file
104
dashboard-frontend/src/features/boss-dashboard/mock.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import type { DashboardOverview } from './types'
|
||||
|
||||
export const dashboardMock: DashboardOverview = {
|
||||
businessDate: '2026-05-10',
|
||||
generatedAt: '2026-05-11 00:10:12',
|
||||
summary: '昨日成交保持稳定,采购用户略有增长;资金风险主要集中在大额待提现和积分比例异常。',
|
||||
kpis: [
|
||||
{ key: 'dealAmount', title: '昨日成交额', value: 1289360.42, unit: '元', trendLabel: '较前日', trendValue: 8.6, status: 'success', featured: true },
|
||||
{ key: 'orderCount', title: '昨日订单数', value: 1842, unit: '单', trendLabel: '较前日', trendValue: 4.1, status: 'success' },
|
||||
{ key: 'purchaseUsers', title: '采购用户', value: 936, unit: '人', trendLabel: '较前日', trendValue: 2.7, status: 'success' },
|
||||
{ key: 'newUsers', title: '新增用户', value: 318, unit: '人', trendLabel: '较前日', trendValue: -3.2, status: 'warning' },
|
||||
{ key: 'newMerchandise', title: '新增寄售商品', value: 472, unit: '件', trendLabel: '较前日', trendValue: 12.4, status: 'success' },
|
||||
{ key: 'selfBonus', title: '个人奖金发放', value: 168230.36, unit: '元', trendLabel: '较前日', trendValue: 6.8, status: 'normal' },
|
||||
{ key: 'shareBonus', title: '推广奖金发放', value: 82460.18, unit: '元', trendLabel: '较前日', trendValue: 1.9, status: 'normal' },
|
||||
{ key: 'pendingAmount', title: '待支付/待结算', value: 95620.11, unit: '元', trendLabel: '需关注', status: 'warning' },
|
||||
],
|
||||
fundPool: [
|
||||
{ key: 'balance', title: '余额总额', value: 728903.22, unit: '元', status: 'normal' },
|
||||
{ key: 'coupon', title: '优惠券总额', value: 391082.88, unit: '元', status: 'normal' },
|
||||
{ key: 'selfBonusPool', title: '个人奖金总额', value: 836942.14, unit: '元', status: 'warning' },
|
||||
{ key: 'shareBonusPool', title: '推广奖金总额', value: 295402.77, unit: '元', status: 'normal' },
|
||||
{ key: 'integral', title: '积分总额', value: 418471.07, unit: '分', status: 'normal' },
|
||||
{ key: 'withdrawPending', title: '待审核提现', value: 63200, unit: '元', status: 'danger' },
|
||||
],
|
||||
snapshots: [
|
||||
{
|
||||
slot: '1015',
|
||||
title: '10:15 上午快报',
|
||||
status: 'success',
|
||||
generatedAt: '2026-05-11 10:15:08',
|
||||
message: '上午抢购节点已完成,上一日寄卖商品消化情况良好,采购用户和成交额略高于昨日同节点。',
|
||||
purchaseUsers: 421,
|
||||
orderCount: 756,
|
||||
dealAmount: 526880.2,
|
||||
paidAmount: 498320.5,
|
||||
newMerchandiseCount: 185,
|
||||
selfBonusChange: 64230.3,
|
||||
shareBonusChange: 31820.1,
|
||||
},
|
||||
{
|
||||
slot: '1455',
|
||||
title: '14:55 下午快报',
|
||||
status: 'pending',
|
||||
message: '下午寄卖/转卖节点尚未生成,预计 14:55 后可查看用户抢购商品的再次上架情况。',
|
||||
purchaseUsers: 0,
|
||||
orderCount: 0,
|
||||
dealAmount: 0,
|
||||
paidAmount: 0,
|
||||
newMerchandiseCount: 0,
|
||||
selfBonusChange: 0,
|
||||
shareBonusChange: 0,
|
||||
},
|
||||
],
|
||||
trends: [
|
||||
{ date: '05-04', amount: 948000, orders: 1390, newUsers: 226, bonus: 186000 },
|
||||
{ date: '05-05', amount: 1024000, orders: 1512, newUsers: 251, bonus: 194000 },
|
||||
{ date: '05-06', amount: 1119000, orders: 1604, newUsers: 287, bonus: 205000 },
|
||||
{ date: '05-07', amount: 1086000, orders: 1542, newUsers: 243, bonus: 198000 },
|
||||
{ date: '05-08', amount: 1198000, orders: 1731, newUsers: 302, bonus: 221000 },
|
||||
{ date: '05-09', amount: 1187200, orders: 1769, newUsers: 329, bonus: 229000 },
|
||||
{ date: '05-10', amount: 1289360, orders: 1842, newUsers: 318, bonus: 250690 },
|
||||
],
|
||||
userRanks: [
|
||||
{ id: 'u1', name: '刘先生', value: 96520, description: '个人奖金 + 推广奖金 + 积分折算', badge: '高价值' },
|
||||
{ id: 'u2', name: '陈女士', value: 81230, description: '昨日采购 12 单', badge: '活跃' },
|
||||
{ id: 'u3', name: '周先生', value: 75880, description: '团队新增 18 人' },
|
||||
],
|
||||
teamRanks: [
|
||||
{ id: 't1', name: '华东一队', value: 386200, description: '成交额第一,团队收益 4.8 万', badge: 'TOP1' },
|
||||
{ id: 't2', name: '苏州团队', value: 318760, description: '采购用户 182 人' },
|
||||
{ id: 't3', name: '扬州团队', value: 287500, description: '新增成员 36 人' },
|
||||
],
|
||||
productRanks: [
|
||||
{ id: 'p1', name: '高端礼盒 A 款', value: 128800, description: '上架 7 天未成交', badge: '滞销' },
|
||||
{ id: 'p2', name: '精选组合 B 款', value: 98600, description: '高货值待成交' },
|
||||
{ id: 'p3', name: '会员专享 C 款', value: 83500, description: '浏览高,成交低' },
|
||||
],
|
||||
risks: [
|
||||
{
|
||||
id: 'r1',
|
||||
level: 'red',
|
||||
type: '资金',
|
||||
title: '大额待审核提现',
|
||||
description: '当前待审核提现 6.32 万,建议今日处理。',
|
||||
discoveredAt: '11:00',
|
||||
},
|
||||
{
|
||||
id: 'r2',
|
||||
level: 'yellow',
|
||||
type: '积分',
|
||||
title: '积分与个人奖金比例异常',
|
||||
description: '发现 3 名用户积分未接近个人奖金的 1/2。',
|
||||
discoveredAt: '10:40',
|
||||
},
|
||||
{
|
||||
id: 'r3',
|
||||
level: 'gray',
|
||||
type: '数据',
|
||||
title: '用户资料不一致',
|
||||
description: 'wa_users 与 eb_user 有 5 条手机号不一致。',
|
||||
discoveredAt: '09:55',
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { Button, DotLoading, ErrorBlock } from 'antd-mobile'
|
||||
import { KpiCard } from '../../../components/kpi/KpiCard'
|
||||
import { MiniTrendChart } from '../../../components/charts/MiniTrendChart'
|
||||
import { formatMoney } from '../../../utils/format'
|
||||
import { useDashboardOverview } from '../api'
|
||||
import { RankList } from '../components/RankList'
|
||||
import { RiskAlertSection } from '../components/RiskAlertSection'
|
||||
import { TodaySnapshotSection } from '../components/TodaySnapshotSection'
|
||||
|
||||
export function BossDashboardPage() {
|
||||
const { data, isLoading, isError, refetch } = useDashboardOverview()
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<section className="loading-page">
|
||||
<DotLoading color="primary" />
|
||||
<p>正在生成经营简报...</p>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
if (isError || !data) {
|
||||
return (
|
||||
<section className="error-page">
|
||||
<ErrorBlock status="default" title="驾驶舱加载失败" description="后端接口暂不可用,请确认服务、登录态或接口权限后重试。" />
|
||||
<Button color="primary" onClick={() => void refetch()}>
|
||||
重新加载
|
||||
</Button>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
const coreKpis = data.kpis.slice(0, 4)
|
||||
const moreKpis = data.kpis.slice(4)
|
||||
|
||||
return (
|
||||
<section className="dashboard-page">
|
||||
<header className="dashboard-hero">
|
||||
<div className="hero-topline">
|
||||
<span>经营驾驶舱</span>
|
||||
<button type="button">上个工作日</button>
|
||||
</div>
|
||||
<p className="eyebrow">数据日期 {data.businessDate}</p>
|
||||
<h1>上个工作日经营简报</h1>
|
||||
<p className="hero-summary">{data.summary}</p>
|
||||
<div className="hero-metric">
|
||||
<span>上个工作日成交额</span>
|
||||
<strong>{formatMoney(data.kpis[0]?.value)}</strong>
|
||||
<small>生成时间:{data.generatedAt}</small>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="kpi-grid" aria-label="核心经营指标">
|
||||
{coreKpis.map((metric) => (
|
||||
<KpiCard key={metric.key} metric={metric} />
|
||||
))}
|
||||
</section>
|
||||
|
||||
<section className="section-block compact-section">
|
||||
<div className="section-title-row">
|
||||
<div>
|
||||
<p className="section-kicker">More</p>
|
||||
<h2>更多经营指标</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div className="kpi-grid kpi-grid--compact">
|
||||
{moreKpis.map((metric) => (
|
||||
<KpiCard key={metric.key} metric={metric} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<TodaySnapshotSection snapshots={data.snapshots} />
|
||||
|
||||
<section className="section-block">
|
||||
<div className="section-title-row">
|
||||
<div>
|
||||
<p className="section-kicker">Trend</p>
|
||||
<h2>近 7 天交易趋势</h2>
|
||||
</div>
|
||||
</div>
|
||||
<MiniTrendChart data={data.trends} />
|
||||
</section>
|
||||
|
||||
<section className="section-block compact-section">
|
||||
<div className="section-title-row">
|
||||
<div>
|
||||
<p className="section-kicker">Fund</p>
|
||||
<h2>资金池摘要</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div className="kpi-grid kpi-grid--compact">
|
||||
{data.fundPool.map((metric) => (
|
||||
<KpiCard key={metric.key} metric={metric} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<RankList title="高价值用户" items={data.userRanks} />
|
||||
<RankList title="团队贡献排行" items={data.teamRanks} />
|
||||
<RankList title="高货值未成交商品" items={data.productRanks} />
|
||||
<RiskAlertSection risks={data.risks} />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,470 @@
|
||||
import { Button, CapsuleTabs, DotLoading, ErrorBlock, Tag, Toast } from 'antd-mobile'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { MiniTrendChart } from '../../../components/charts/MiniTrendChart'
|
||||
import { KpiCard } from '../../../components/kpi/KpiCard'
|
||||
import { formatMoney, formatNumber } from '../../../utils/format'
|
||||
import { useDashboardOverview } from '../api'
|
||||
import { buildDailyReportArchiveHtml } from '../archive'
|
||||
import type { DashboardOverview, RiskLevel, SnapshotSlot, TodaySnapshot } from '../types'
|
||||
|
||||
const snapshotStatusMeta = {
|
||||
pending: { color: 'default', label: '待生成' },
|
||||
success: { color: 'success', label: '已生成' },
|
||||
failed: { color: 'danger', label: '失败' },
|
||||
temporary: { color: 'warning', label: '临时' },
|
||||
} as const
|
||||
|
||||
const riskLevelMeta: Record<RiskLevel, { color: 'danger' | 'warning' | 'default'; label: string }> = {
|
||||
red: { color: 'danger', label: '红色' },
|
||||
yellow: { color: 'warning', label: '黄色' },
|
||||
gray: { color: 'default', label: '灰色' },
|
||||
}
|
||||
|
||||
const snapshotSlotMeta: Record<
|
||||
SnapshotSlot,
|
||||
{
|
||||
title: string
|
||||
subtitle: string
|
||||
metricLabels: {
|
||||
primaryUsers: string
|
||||
primaryOrders: string
|
||||
amount: string
|
||||
paidAmount: string
|
||||
merchandise: string
|
||||
bonus: string
|
||||
}
|
||||
checklist: string[]
|
||||
}
|
||||
> = {
|
||||
'1015': {
|
||||
title: '上午抢购快报',
|
||||
subtitle: '用户集中抢购上一天用户寄卖的商品,重点看成交、付款和采购用户是否达标。',
|
||||
metricLabels: {
|
||||
primaryUsers: '抢购用户',
|
||||
primaryOrders: '抢购订单',
|
||||
amount: '抢购成交额',
|
||||
paidAmount: '已支付金额',
|
||||
merchandise: '成交商品',
|
||||
bonus: '相关奖金',
|
||||
},
|
||||
checklist: ['抢购成交额是否低于昨日同节点', '采购用户是否异常回落', '付款金额与成交额是否明显偏离', '高货值寄卖商品是否完成消化'],
|
||||
},
|
||||
'1455': {
|
||||
title: '下午寄卖/转卖快报',
|
||||
subtitle: '用户把上午抢到的商品继续寄卖或转卖,重点看新增寄售供给和奖金变化是否正常。',
|
||||
metricLabels: {
|
||||
primaryUsers: '寄卖用户',
|
||||
primaryOrders: '转卖订单',
|
||||
amount: '转卖成交额',
|
||||
paidAmount: '回款金额',
|
||||
merchandise: '新增寄售',
|
||||
bonus: '奖金变化',
|
||||
},
|
||||
checklist: ['抢购商品是否按预期转入寄卖', '新增寄售商品是否满足下午供给', '个人奖金与推广奖金是否同步变化', '转卖回款是否出现异常延迟'],
|
||||
},
|
||||
}
|
||||
|
||||
function QueryState({
|
||||
isLoading,
|
||||
isError,
|
||||
refetch,
|
||||
title,
|
||||
}: {
|
||||
isLoading: boolean
|
||||
isError: boolean
|
||||
refetch: () => void
|
||||
title: string
|
||||
}) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<section className="loading-page">
|
||||
<DotLoading color="primary" />
|
||||
<p>正在加载{title}...</p>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<section className="error-page">
|
||||
<ErrorBlock status="default" title={`${title}加载失败`} description="后端接口暂不可用,请确认服务、登录态或接口权限后重试。" />
|
||||
<Button color="primary" onClick={refetch}>
|
||||
重新加载
|
||||
</Button>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function OperationsHeader({
|
||||
kicker,
|
||||
title,
|
||||
description,
|
||||
extra,
|
||||
}: {
|
||||
kicker: string
|
||||
title: string
|
||||
description: string
|
||||
extra?: string
|
||||
}) {
|
||||
return (
|
||||
<header className="operations-header">
|
||||
<p className="eyebrow">{kicker}</p>
|
||||
<h1>{title}</h1>
|
||||
<p>{description}</p>
|
||||
{extra && <span>{extra}</span>}
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
function SnapshotDetailCard({ snapshot }: { snapshot: TodaySnapshot }) {
|
||||
const status = snapshotStatusMeta[snapshot.status]
|
||||
const slotMeta = snapshotSlotMeta[snapshot.slot]
|
||||
|
||||
return (
|
||||
<article className="snapshot-detail-card">
|
||||
<div className="section-title-row">
|
||||
<div>
|
||||
<p className="section-kicker">{snapshot.slot}</p>
|
||||
<h2>{slotMeta.title}</h2>
|
||||
</div>
|
||||
<Tag color={status.color}>{status.label}</Tag>
|
||||
</div>
|
||||
<p className="snapshot-detail-subtitle">{slotMeta.subtitle}</p>
|
||||
<p className="snapshot-detail-message">{snapshot.message}</p>
|
||||
{snapshot.generatedAt && <p className="snapshot-time">生成时间:{snapshot.generatedAt}</p>}
|
||||
<div className="snapshot-grid snapshot-grid--wide">
|
||||
<span>
|
||||
{slotMeta.metricLabels.primaryUsers}
|
||||
<strong>{formatNumber(snapshot.purchaseUsers)}人</strong>
|
||||
</span>
|
||||
<span>
|
||||
{slotMeta.metricLabels.primaryOrders}
|
||||
<strong>{formatNumber(snapshot.orderCount)}单</strong>
|
||||
</span>
|
||||
<span>
|
||||
{slotMeta.metricLabels.amount}
|
||||
<strong>{formatMoney(snapshot.dealAmount)}</strong>
|
||||
</span>
|
||||
<span>
|
||||
{slotMeta.metricLabels.paidAmount}
|
||||
<strong>{formatMoney(snapshot.paidAmount)}</strong>
|
||||
</span>
|
||||
<span>
|
||||
{slotMeta.metricLabels.merchandise}
|
||||
<strong>{formatNumber(snapshot.newMerchandiseCount)}件</strong>
|
||||
</span>
|
||||
<span>
|
||||
{slotMeta.metricLabels.bonus}
|
||||
<strong>{formatMoney(Number(snapshot.selfBonusChange) + Number(snapshot.shareBonusChange))}</strong>
|
||||
</span>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
function buildDailyReports(data: DashboardOverview) {
|
||||
return data.trends
|
||||
.slice(-4)
|
||||
.reverse()
|
||||
.map((trend, index) => ({
|
||||
...trend,
|
||||
status: index === 0 ? '已生成' : '历史快照',
|
||||
bonusRate: Number(trend.amount) > 0 ? (Number(trend.bonus) / Number(trend.amount)) * 100 : 0,
|
||||
}))
|
||||
}
|
||||
|
||||
export function DailyReportPage() {
|
||||
const { data, isLoading, isError, refetch } = useDashboardOverview()
|
||||
const [isArchiving, setIsArchiving] = useState(false)
|
||||
const state = <QueryState isLoading={isLoading} isError={isError || !data} refetch={() => void refetch()} title="经营日报" />
|
||||
|
||||
if (!data) return state
|
||||
|
||||
const reports = buildDailyReports(data)
|
||||
|
||||
const handleArchive = async () => {
|
||||
try {
|
||||
setIsArchiving(true)
|
||||
const html = buildDailyReportArchiveHtml(data)
|
||||
const blob = new Blob([html], { type: 'text/html;charset=utf-8' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = `dashboard-daily-report-${data.businessDate}.html`
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
link.remove()
|
||||
URL.revokeObjectURL(url)
|
||||
Toast.show({ icon: 'success', content: '归档 HTML 已生成' })
|
||||
} catch {
|
||||
Toast.show({ icon: 'fail', content: '归档生成失败,请稍后重试' })
|
||||
} finally {
|
||||
setIsArchiving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="operations-page">
|
||||
<OperationsHeader
|
||||
kicker="Daily Report"
|
||||
title="经营日报"
|
||||
description="按日沉淀成交、订单、用户与奖金变化,方便老板回看最近经营节奏。"
|
||||
extra={`最新数据:${data.businessDate}`}
|
||||
/>
|
||||
|
||||
<section className="section-block compact-section">
|
||||
<div className="section-title-row">
|
||||
<div>
|
||||
<p className="section-kicker">Workday</p>
|
||||
<h2>上个工作日重点</h2>
|
||||
</div>
|
||||
<Tag color="success">已归档</Tag>
|
||||
</div>
|
||||
<div className="kpi-grid kpi-grid--compact">
|
||||
{data.kpis.slice(0, 4).map((metric) => (
|
||||
<KpiCard key={metric.key} metric={metric} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="section-block">
|
||||
<div className="section-title-row">
|
||||
<div>
|
||||
<p className="section-kicker">Trend</p>
|
||||
<h2>最近 7 天趋势</h2>
|
||||
</div>
|
||||
</div>
|
||||
<MiniTrendChart data={data.trends} />
|
||||
</section>
|
||||
|
||||
<section className="section-block">
|
||||
<div className="section-title-row">
|
||||
<div>
|
||||
<p className="section-kicker">Archive</p>
|
||||
<h2>日报归档</h2>
|
||||
</div>
|
||||
<button className="text-button" type="button" disabled={isArchiving} onClick={() => void handleArchive()}>
|
||||
{isArchiving ? '生成中...' : '生成归档'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="report-list">
|
||||
{reports.map((report) => (
|
||||
<button className="report-item" key={report.date} type="button">
|
||||
<span>
|
||||
<strong>{report.date}</strong>
|
||||
<small>{report.status}</small>
|
||||
</span>
|
||||
<span>
|
||||
<strong>{formatMoney(report.amount)}</strong>
|
||||
<small>
|
||||
{formatNumber(report.orders)} 单 / 奖金占比 {formatNumber(report.bonusRate, 1)}%
|
||||
</small>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export function TodaySnapshotPage() {
|
||||
const { data, isLoading, isError, refetch } = useDashboardOverview()
|
||||
const state = <QueryState isLoading={isLoading} isError={isError || !data} refetch={() => void refetch()} title="今日快报" />
|
||||
|
||||
if (!data) return state
|
||||
|
||||
return (
|
||||
<section className="operations-page">
|
||||
<OperationsHeader
|
||||
kicker="Today Snapshot"
|
||||
title="今日快报"
|
||||
description="10:15 看上一日寄卖商品的抢购结果,14:55 看抢到商品的寄卖/转卖承接情况。"
|
||||
extra="节点状态随 Mock 场景切换"
|
||||
/>
|
||||
|
||||
<section className="section-block snapshot-page-section">
|
||||
<div className="section-title-row">
|
||||
<div>
|
||||
<p className="section-kicker">Timeline</p>
|
||||
<h2>节点快报</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div className="snapshot-stack">
|
||||
{data.snapshots.map((snapshot) => (
|
||||
<SnapshotDetailCard key={snapshot.slot} snapshot={snapshot} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="section-block">
|
||||
<div className="section-title-row">
|
||||
<div>
|
||||
<p className="section-kicker">Checklist</p>
|
||||
<h2>节点检查项</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div className="check-list">
|
||||
{data.snapshots.flatMap((snapshot) =>
|
||||
snapshotSlotMeta[snapshot.slot].checklist.map((item) => (
|
||||
<span key={`${snapshot.slot}-${item}`}>
|
||||
<strong>{snapshot.slot === '1015' ? '上午' : '下午'}</strong>
|
||||
{item}
|
||||
</span>
|
||||
)),
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export function RiskCenterPage() {
|
||||
const { data, isLoading, isError, refetch } = useDashboardOverview()
|
||||
const [activeLevel, setActiveLevel] = useState<RiskLevel | 'all'>('all')
|
||||
const state = <QueryState isLoading={isLoading} isError={isError || !data} refetch={() => void refetch()} title="风险中心" />
|
||||
|
||||
const filteredRisks = useMemo(() => {
|
||||
if (!data) return []
|
||||
if (activeLevel === 'all') return data.risks
|
||||
return data.risks.filter((risk) => risk.level === activeLevel)
|
||||
}, [activeLevel, data])
|
||||
|
||||
if (!data) return state
|
||||
|
||||
const dangerousFunds = data.fundPool.filter((metric) => metric.status === 'warning' || metric.status === 'danger')
|
||||
|
||||
return (
|
||||
<section className="operations-page">
|
||||
<OperationsHeader
|
||||
kicker="Risk Center"
|
||||
title="风险中心"
|
||||
description="把资金、积分与数据一致性风险集中处理,优先看红色和黄色事项。"
|
||||
extra={`${data.risks.length} 条待关注`}
|
||||
/>
|
||||
|
||||
<section className="risk-summary-grid" aria-label="风险概览">
|
||||
{(['red', 'yellow', 'gray'] as const).map((level) => {
|
||||
const meta = riskLevelMeta[level]
|
||||
const count = data.risks.filter((risk) => risk.level === level).length
|
||||
return (
|
||||
<button className={`risk-summary-card risk-summary-card--${level}`} key={level} type="button" onClick={() => setActiveLevel(level)}>
|
||||
<Tag color={meta.color}>{meta.label}</Tag>
|
||||
<strong>{count}</strong>
|
||||
<span>条风险</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</section>
|
||||
|
||||
<section className="section-block">
|
||||
<CapsuleTabs activeKey={activeLevel} onChange={(key) => setActiveLevel(key as RiskLevel | 'all')}>
|
||||
<CapsuleTabs.Tab title="全部" key="all" />
|
||||
<CapsuleTabs.Tab title="红色" key="red" />
|
||||
<CapsuleTabs.Tab title="黄色" key="yellow" />
|
||||
<CapsuleTabs.Tab title="灰色" key="gray" />
|
||||
</CapsuleTabs>
|
||||
<div className="risk-list">
|
||||
{filteredRisks.map((risk) => {
|
||||
const meta = riskLevelMeta[risk.level]
|
||||
return (
|
||||
<button className="risk-item" key={risk.id} type="button">
|
||||
<div className="risk-header">
|
||||
<Tag color={meta.color}>{meta.label}</Tag>
|
||||
<span>{risk.type}</span>
|
||||
<time>{risk.discoveredAt}</time>
|
||||
</div>
|
||||
<strong>{risk.title}</strong>
|
||||
<p>{risk.description}</p>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="section-block compact-section">
|
||||
<div className="section-title-row">
|
||||
<div>
|
||||
<p className="section-kicker">Fund Watch</p>
|
||||
<h2>资金池关注项</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div className="kpi-grid kpi-grid--compact">
|
||||
{dangerousFunds.map((metric) => (
|
||||
<KpiCard key={metric.key} metric={metric} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export function ProfilePage() {
|
||||
const { data, isLoading, isError, refetch } = useDashboardOverview()
|
||||
const state = <QueryState isLoading={isLoading} isError={isError || !data} refetch={() => void refetch()} title="我的" />
|
||||
|
||||
if (!data) return state
|
||||
|
||||
return (
|
||||
<section className="operations-page">
|
||||
<OperationsHeader
|
||||
kicker="Profile"
|
||||
title="我的"
|
||||
description="展示当前驾驶舱权限、数据环境与演示版本,方便联调时确认口径。"
|
||||
extra="老板驾驶舱 H5"
|
||||
/>
|
||||
|
||||
<section className="profile-card">
|
||||
<div className="profile-avatar" aria-hidden="true">
|
||||
老
|
||||
</div>
|
||||
<div>
|
||||
<h2>老板视角</h2>
|
||||
<p>可查看经营概览、今日快报、排行与风险预警。</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="section-block">
|
||||
<div className="section-title-row">
|
||||
<div>
|
||||
<p className="section-kicker">Environment</p>
|
||||
<h2>数据环境</h2>
|
||||
</div>
|
||||
<Tag color="warning">Mock</Tag>
|
||||
</div>
|
||||
<div className="info-list">
|
||||
<span>
|
||||
<small>数据日期</small>
|
||||
<strong>{data.businessDate}</strong>
|
||||
</span>
|
||||
<span>
|
||||
<small>生成时间</small>
|
||||
<strong>{data.generatedAt}</strong>
|
||||
</span>
|
||||
<span>
|
||||
<small>API 模式</small>
|
||||
<strong>{import.meta.env.VITE_MOCK_ENABLED === 'false' ? '真实接口' : 'Mock 演示'}</strong>
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="section-block">
|
||||
<div className="section-title-row">
|
||||
<div>
|
||||
<p className="section-kicker">Permissions</p>
|
||||
<h2>权限模块</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div className="check-list">
|
||||
<span>经营概览:可见</span>
|
||||
<span>资金池摘要:可见</span>
|
||||
<span>风险预警:可见</span>
|
||||
<span>导出能力:待接入</span>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
72
dashboard-frontend/src/features/boss-dashboard/types.ts
Normal file
72
dashboard-frontend/src/features/boss-dashboard/types.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
export type MetricStatus = 'normal' | 'success' | 'warning' | 'danger'
|
||||
|
||||
export type SnapshotStatus = 'pending' | 'success' | 'failed' | 'temporary'
|
||||
|
||||
export type SnapshotSlot = '1015' | '1455'
|
||||
|
||||
export type KpiMetric = {
|
||||
key: string
|
||||
title: string
|
||||
value: number | string | null
|
||||
unit?: string
|
||||
trendLabel?: string
|
||||
trendValue?: number | string
|
||||
status: MetricStatus
|
||||
featured?: boolean
|
||||
}
|
||||
|
||||
export type TodaySnapshot = {
|
||||
slot: SnapshotSlot
|
||||
title: string
|
||||
status: SnapshotStatus
|
||||
generatedAt?: string
|
||||
message: string
|
||||
purchaseUsers: number
|
||||
orderCount: number
|
||||
dealAmount: number | string
|
||||
paidAmount: number | string
|
||||
newMerchandiseCount: number
|
||||
selfBonusChange: number | string
|
||||
shareBonusChange: number | string
|
||||
}
|
||||
|
||||
export type TrendPoint = {
|
||||
date: string
|
||||
amount: number | string
|
||||
orders: number
|
||||
newUsers: number
|
||||
bonus: number | string
|
||||
}
|
||||
|
||||
export type RankItem = {
|
||||
id: string
|
||||
name: string
|
||||
value: number | string
|
||||
description: string
|
||||
badge?: string
|
||||
}
|
||||
|
||||
export type RiskLevel = 'red' | 'yellow' | 'gray'
|
||||
|
||||
export type RiskAlert = {
|
||||
id: string
|
||||
level: RiskLevel
|
||||
type: string
|
||||
title: string
|
||||
description: string
|
||||
discoveredAt: string
|
||||
}
|
||||
|
||||
export type DashboardOverview = {
|
||||
businessDate: string
|
||||
generatedAt: string
|
||||
summary: string
|
||||
kpis: KpiMetric[]
|
||||
fundPool: KpiMetric[]
|
||||
snapshots: TodaySnapshot[]
|
||||
trends: TrendPoint[]
|
||||
userRanks: RankItem[]
|
||||
teamRanks: RankItem[]
|
||||
productRanks: RankItem[]
|
||||
risks: RiskAlert[]
|
||||
}
|
||||
18
dashboard-frontend/src/features/common/PlaceholderPage.tsx
Normal file
18
dashboard-frontend/src/features/common/PlaceholderPage.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Empty } from 'antd-mobile'
|
||||
|
||||
type PlaceholderPageProps = {
|
||||
title: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export function PlaceholderPage({ title, description }: PlaceholderPageProps) {
|
||||
return (
|
||||
<section className="placeholder-page">
|
||||
<div className="mobile-page-header">
|
||||
<p className="eyebrow">经营驾驶舱</p>
|
||||
<h1>{title}</h1>
|
||||
</div>
|
||||
<Empty description={description} />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
589
dashboard-frontend/src/index.css
Normal file
589
dashboard-frontend/src/index.css
Normal file
@@ -0,0 +1,589 @@
|
||||
:root {
|
||||
--bg: #fff6f1;
|
||||
--surface: #ffffff;
|
||||
--surface-soft: #f6f9fb;
|
||||
--text: #132033;
|
||||
--muted: #6b7a90;
|
||||
--border: rgba(19, 32, 51, 0.08);
|
||||
--primary: #ff5b36;
|
||||
--primary-deep: #f04a2a;
|
||||
--primary-soft: #fff0eb;
|
||||
--success: #14a46c;
|
||||
--warning: #ffb000;
|
||||
--danger: #dc2626;
|
||||
--shadow: 0 16px 40px rgba(255, 91, 54, 0.14);
|
||||
--radius-xl: 28px;
|
||||
--radius-lg: 20px;
|
||||
--radius-md: 14px;
|
||||
font-family:
|
||||
ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
--adm-color-primary: var(--primary);
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 320px;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(255, 91, 54, 0.2), transparent 28rem),
|
||||
var(--bg);
|
||||
}
|
||||
|
||||
button {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button:focus-visible {
|
||||
outline: 2px solid rgba(255, 91, 54, 0.72);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
#root {
|
||||
width: min(100%, 430px);
|
||||
min-height: 100svh;
|
||||
margin: 0 auto;
|
||||
background: var(--bg);
|
||||
box-shadow: 0 0 0 1px rgba(19, 32, 51, 0.04);
|
||||
}
|
||||
|
||||
.mobile-shell {
|
||||
min-height: 100svh;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.mobile-main {
|
||||
min-height: 100svh;
|
||||
padding-bottom: calc(74px + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.bottom-nav {
|
||||
position: fixed;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
z-index: 20;
|
||||
width: min(100%, 430px);
|
||||
margin: 0 auto;
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
border-top: 1px solid var(--border);
|
||||
backdrop-filter: blur(16px);
|
||||
}
|
||||
|
||||
.dashboard-page {
|
||||
padding: 14px 14px 24px;
|
||||
}
|
||||
|
||||
.dashboard-hero {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
padding: 20px;
|
||||
color: #fff;
|
||||
background:
|
||||
linear-gradient(145deg, rgba(255, 91, 54, 0.98), rgba(255, 139, 82, 0.92)),
|
||||
radial-gradient(circle at 90% 10%, rgba(255, 176, 0, 0.42), transparent 18rem);
|
||||
border-radius: 0 0 var(--radius-xl) var(--radius-xl);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.hero-topline,
|
||||
.section-title-row,
|
||||
.risk-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.hero-topline span,
|
||||
.eyebrow,
|
||||
.section-kicker {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.hero-topline button {
|
||||
min-height: 34px;
|
||||
padding: 0 14px;
|
||||
color: #fff;
|
||||
background: rgba(255, 255, 255, 0.16);
|
||||
border: 1px solid rgba(255, 255, 255, 0.24);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.dashboard-hero h1 {
|
||||
margin: 18px 0 8px;
|
||||
font-size: 30px;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.hero-summary {
|
||||
margin: 0;
|
||||
color: rgba(255, 255, 255, 0.78);
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.hero-metric {
|
||||
margin-top: 20px;
|
||||
padding: 16px;
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.hero-metric span,
|
||||
.hero-metric small {
|
||||
display: block;
|
||||
color: rgba(255, 255, 255, 0.72);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.hero-metric strong {
|
||||
display: block;
|
||||
margin: 6px 0;
|
||||
font-size: 32px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.kpi-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.kpi-card {
|
||||
min-height: 112px;
|
||||
padding: 14px;
|
||||
text-align: left;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: 0 10px 28px rgba(22, 47, 80, 0.08);
|
||||
}
|
||||
|
||||
.kpi-card--featured {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.kpi-title,
|
||||
.kpi-trend,
|
||||
.section-kicker,
|
||||
.snapshot-time,
|
||||
.rank-content small,
|
||||
.risk-item p {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.kpi-value {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
color: var(--text);
|
||||
font-size: 22px;
|
||||
line-height: 1.08;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.kpi-trend {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.kpi-trend span {
|
||||
margin-left: 6px;
|
||||
color: var(--primary);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.kpi-card--success .kpi-trend span {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.kpi-card--warning .kpi-trend span {
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.kpi-card--danger .kpi-value,
|
||||
.kpi-card--danger .kpi-trend span {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.section-block {
|
||||
margin-top: 14px;
|
||||
padding: 16px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-xl);
|
||||
box-shadow: 0 10px 28px rgba(22, 47, 80, 0.08);
|
||||
}
|
||||
|
||||
.section-title-row h2 {
|
||||
margin: 2px 0 0;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.compact-section .kpi-grid {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.snapshot-section .adm-capsule-tabs {
|
||||
margin: 14px 0;
|
||||
}
|
||||
|
||||
.snapshot-card {
|
||||
padding: 14px;
|
||||
background: var(--surface-soft);
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.snapshot-message {
|
||||
margin: 0 0 8px;
|
||||
font-weight: 700;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.snapshot-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.snapshot-grid span {
|
||||
min-height: 64px;
|
||||
padding: 10px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
background: #fff;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.snapshot-grid strong {
|
||||
display: block;
|
||||
margin-top: 5px;
|
||||
color: var(--text);
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.mini-trend-chart {
|
||||
width: 100%;
|
||||
height: 240px;
|
||||
}
|
||||
|
||||
.text-button,
|
||||
.rank-item,
|
||||
.risk-item {
|
||||
appearance: none;
|
||||
border: 0;
|
||||
background: none;
|
||||
}
|
||||
|
||||
.text-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 36px;
|
||||
color: var(--primary);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.text-button:disabled {
|
||||
color: var(--muted);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.rank-list,
|
||||
.risk-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.rank-item,
|
||||
.risk-item {
|
||||
width: 100%;
|
||||
min-height: 58px;
|
||||
padding: 12px;
|
||||
text-align: left;
|
||||
background: var(--surface-soft);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.rank-item {
|
||||
display: grid;
|
||||
grid-template-columns: 28px 1fr auto;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.rank-index {
|
||||
display: inline-grid;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
place-items: center;
|
||||
color: #fff;
|
||||
font-weight: 800;
|
||||
background: var(--text);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.rank-content strong,
|
||||
.rank-content small {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.rank-value {
|
||||
color: var(--primary);
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.risk-count {
|
||||
color: var(--danger);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.risk-header {
|
||||
justify-content: flex-start;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.risk-header time {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.risk-item strong {
|
||||
display: block;
|
||||
margin: 10px 0 4px;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.risk-item p {
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.placeholder-page,
|
||||
.loading-page,
|
||||
.error-page {
|
||||
padding: 24px 16px;
|
||||
}
|
||||
|
||||
.mobile-page-header h1 {
|
||||
margin: 4px 0 24px;
|
||||
}
|
||||
|
||||
.operations-page {
|
||||
padding: 14px 14px 24px;
|
||||
}
|
||||
|
||||
.operations-header {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
padding: 20px;
|
||||
color: #fff;
|
||||
background:
|
||||
linear-gradient(145deg, rgba(255, 91, 54, 0.98), rgba(255, 139, 82, 0.92)),
|
||||
radial-gradient(circle at 90% 10%, rgba(255, 176, 0, 0.42), transparent 18rem);
|
||||
border-radius: 0 0 var(--radius-xl) var(--radius-xl);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.operations-header h1 {
|
||||
margin: 12px 0 8px;
|
||||
font-size: 28px;
|
||||
line-height: 1.12;
|
||||
}
|
||||
|
||||
.operations-header p {
|
||||
margin: 0;
|
||||
color: rgba(255, 255, 255, 0.76);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.operations-header .eyebrow {
|
||||
color: rgba(255, 255, 255, 0.68);
|
||||
}
|
||||
|
||||
.operations-header span {
|
||||
display: inline-flex;
|
||||
margin-top: 16px;
|
||||
padding: 7px 12px;
|
||||
color: rgba(255, 255, 255, 0.86);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.report-list,
|
||||
.check-list,
|
||||
.info-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.report-item {
|
||||
display: grid;
|
||||
grid-template-columns: 82px 1fr;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
padding: 13px;
|
||||
text-align: left;
|
||||
background: var(--surface-soft);
|
||||
border: 0;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.report-item span,
|
||||
.info-list span {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.report-item small,
|
||||
.info-list small,
|
||||
.profile-card p {
|
||||
color: var(--muted);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.snapshot-stack {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.snapshot-detail-card {
|
||||
padding: 14px;
|
||||
background: var(--surface-soft);
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.snapshot-detail-subtitle {
|
||||
margin: 12px 0 0;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.snapshot-detail-message {
|
||||
margin: 10px 0 8px;
|
||||
font-weight: 700;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.snapshot-grid--wide {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.check-list span,
|
||||
.info-list span {
|
||||
padding: 12px;
|
||||
background: var(--surface-soft);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.check-list span {
|
||||
color: var(--text);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.check-list strong {
|
||||
margin-right: 8px;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.risk-summary-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.risk-summary-card {
|
||||
display: grid;
|
||||
min-height: 104px;
|
||||
padding: 12px;
|
||||
text-align: left;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: 0 10px 28px rgba(22, 47, 80, 0.08);
|
||||
}
|
||||
|
||||
.risk-summary-card strong {
|
||||
margin-top: 8px;
|
||||
font-size: 28px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.risk-summary-card span:last-child {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.risk-summary-card--red strong {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.risk-summary-card--yellow strong {
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.risk-summary-card--gray strong {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.profile-card {
|
||||
display: grid;
|
||||
grid-template-columns: 58px 1fr;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
margin-top: 14px;
|
||||
padding: 16px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-xl);
|
||||
box-shadow: 0 10px 28px rgba(22, 47, 80, 0.08);
|
||||
}
|
||||
|
||||
.profile-card h2,
|
||||
.profile-card p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.profile-avatar {
|
||||
display: grid;
|
||||
width: 58px;
|
||||
height: 58px;
|
||||
place-items: center;
|
||||
color: #fff;
|
||||
font-size: 22px;
|
||||
font-weight: 800;
|
||||
background: linear-gradient(145deg, var(--primary), var(--warning));
|
||||
border-radius: 22px;
|
||||
}
|
||||
|
||||
.loading-page,
|
||||
.error-page {
|
||||
display: grid;
|
||||
min-height: 60svh;
|
||||
place-content: center;
|
||||
gap: 14px;
|
||||
text-align: center;
|
||||
}
|
||||
20
dashboard-frontend/src/main.tsx
Normal file
20
dashboard-frontend/src/main.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import 'antd-mobile/es/global'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
const startApp = async () => {
|
||||
if (import.meta.env.VITE_MOCK_ENABLED !== 'false') {
|
||||
const { worker } = await import('./services/mock/browser')
|
||||
await worker.start({ onUnhandledRequest: 'bypass' })
|
||||
}
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
}
|
||||
|
||||
void startApp()
|
||||
26
dashboard-frontend/src/services/http/client.ts
Normal file
26
dashboard-frontend/src/services/http/client.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import axios from 'axios'
|
||||
|
||||
export const httpClient = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_BASE_URL ?? '',
|
||||
timeout: 8000,
|
||||
})
|
||||
|
||||
export type ApiResponse<T> = {
|
||||
code: number
|
||||
message?: string
|
||||
msg?: string
|
||||
data: T
|
||||
}
|
||||
|
||||
export async function getApiData<T>(url: string): Promise<T> {
|
||||
const response = await httpClient.get<ApiResponse<T>>(url)
|
||||
if (response.data.code !== 0 && response.data.code !== 200) {
|
||||
throw new Error(response.data.msg ?? response.data.message ?? '接口请求失败')
|
||||
}
|
||||
return response.data.data
|
||||
}
|
||||
|
||||
export async function getBlob(url: string): Promise<Blob> {
|
||||
const response = await httpClient.get<Blob>(url, { responseType: 'blob' })
|
||||
return response.data
|
||||
}
|
||||
4
dashboard-frontend/src/services/mock/browser.ts
Normal file
4
dashboard-frontend/src/services/mock/browser.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { setupWorker } from 'msw/browser'
|
||||
import { handlers } from './handlers'
|
||||
|
||||
export const worker = setupWorker(...handlers)
|
||||
58
dashboard-frontend/src/services/mock/handlers.ts
Normal file
58
dashboard-frontend/src/services/mock/handlers.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { dashboardMock } from '../../features/boss-dashboard/mock'
|
||||
|
||||
function buildArchiveHtml() {
|
||||
return `<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>经营日报归档 - ${dashboardMock.businessDate}</title>
|
||||
<style>
|
||||
body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; color: #132033; background: #fff6f1; }
|
||||
main { max-width: 820px; margin: 0 auto; padding: 28px 18px 40px; }
|
||||
header { color: #fff; padding: 26px; border-radius: 0 0 28px 28px; background: linear-gradient(145deg, #ff5b36, #ff8b52); }
|
||||
section { margin-top: 16px; padding: 18px; background: #fff; border-radius: 24px; }
|
||||
.grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
|
||||
article { padding: 14px; border-radius: 18px; background: #f6f9fb; }
|
||||
small { color: #6b7a90; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<header>
|
||||
<p>Daily Report Archive</p>
|
||||
<h1>经营日报归档</h1>
|
||||
<p>${dashboardMock.summary}</p>
|
||||
<small>数据日期:${dashboardMock.businessDate} / 生成时间:${dashboardMock.generatedAt}</small>
|
||||
</header>
|
||||
<section>
|
||||
<h2>核心经营指标</h2>
|
||||
<div class="grid">
|
||||
${dashboardMock.kpis
|
||||
.map((metric) => `<article><small>${metric.title}</small><h3>${metric.value}${metric.unit ?? ''}</h3><small>${metric.trendLabel ?? ''}</small></article>`)
|
||||
.join('')}
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>`
|
||||
}
|
||||
|
||||
export const handlers = [
|
||||
http.get('/api/admin/dashboard/overview', () => {
|
||||
return HttpResponse.json({
|
||||
code: 0,
|
||||
msg: 'success',
|
||||
data: dashboardMock,
|
||||
})
|
||||
}),
|
||||
http.get('/api/admin/dashboard/daily-report/archive', () => {
|
||||
return new HttpResponse(buildArchiveHtml(), {
|
||||
headers: {
|
||||
'Content-Type': 'text/html; charset=utf-8',
|
||||
'Content-Disposition': `attachment; filename="dashboard-daily-report-${dashboardMock.businessDate}.html"`,
|
||||
},
|
||||
})
|
||||
}),
|
||||
]
|
||||
22
dashboard-frontend/src/utils/format.test.ts
Normal file
22
dashboard-frontend/src/utils/format.test.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { formatMetricValue, formatMoney, formatNumber, formatTrend } from './format'
|
||||
|
||||
describe('format helpers', () => {
|
||||
it('formats money with yuan symbol and two decimals', () => {
|
||||
expect(formatMoney(1289360.4)).toBe('¥1,289,360.40')
|
||||
})
|
||||
|
||||
it('formats metric values based on unit', () => {
|
||||
expect(formatMetricValue(418471.07, '分')).toBe('418,471.070')
|
||||
expect(formatMetricValue(936, '人')).toBe('936人')
|
||||
})
|
||||
|
||||
it('uses placeholder for empty values', () => {
|
||||
expect(formatNumber(null)).toBe('--')
|
||||
})
|
||||
|
||||
it('adds plus sign for positive trend values', () => {
|
||||
expect(formatTrend(8.6)).toBe('+8.6%')
|
||||
expect(formatTrend(-3.2)).toBe('-3.2%')
|
||||
})
|
||||
})
|
||||
33
dashboard-frontend/src/utils/format.ts
Normal file
33
dashboard-frontend/src/utils/format.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
export function formatMoney(value: number | string | null | undefined): string {
|
||||
if (value === null || value === undefined || value === '') return '--'
|
||||
const numberValue = Number(value)
|
||||
if (Number.isNaN(numberValue)) return String(value)
|
||||
return `¥${numberValue.toLocaleString('zh-CN', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})}`
|
||||
}
|
||||
|
||||
export function formatNumber(value: number | string | null | undefined, digits = 0): string {
|
||||
if (value === null || value === undefined || value === '') return '--'
|
||||
const numberValue = Number(value)
|
||||
if (Number.isNaN(numberValue)) return String(value)
|
||||
return numberValue.toLocaleString('zh-CN', {
|
||||
minimumFractionDigits: digits,
|
||||
maximumFractionDigits: digits,
|
||||
})
|
||||
}
|
||||
|
||||
export function formatMetricValue(value: number | string | null, unit?: string): string {
|
||||
if (unit === '元') return formatMoney(value)
|
||||
if (unit === '分') return formatNumber(value, 3)
|
||||
return `${formatNumber(value)}${unit ?? ''}`
|
||||
}
|
||||
|
||||
export function formatTrend(value?: number | string): string {
|
||||
if (value === undefined || value === '') return ''
|
||||
const numberValue = Number(value)
|
||||
if (Number.isNaN(numberValue)) return String(value)
|
||||
const prefix = numberValue > 0 ? '+' : ''
|
||||
return `${prefix}${numberValue.toFixed(1)}%`
|
||||
}
|
||||
25
dashboard-frontend/tsconfig.app.json
Normal file
25
dashboard-frontend/tsconfig.app.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||
"module": "esnext",
|
||||
"types": ["vite/client"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
7
dashboard-frontend/tsconfig.json
Normal file
7
dashboard-frontend/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
24
dashboard-frontend/tsconfig.node.json
Normal file
24
dashboard-frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "esnext",
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
17
dashboard-frontend/vite.config.ts
Normal file
17
dashboard-frontend/vite.config.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
port: 5174,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:30032',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user