Merge branch 'sxsy80' into czleilei240

This commit is contained in:
danaisuiyuan
2026-05-16 18:36:18 +08:00
45 changed files with 7246 additions and 378 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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();
}
}

View File

@@ -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
}
}

View 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';

View 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;

View 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
View 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?

View 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...
},
},
])
```

View 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,
},
},
])

View 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>

View 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

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

View 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

View 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,
}
}

View 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

View 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>
)
}

View 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>
)
}

View 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 天交易趋势图" />
}

View 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>
)
}

View 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')
}

View 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('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;')
}
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>`
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View 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',
},
],
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View 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[]
}

View 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>
)
}

View 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;
}

View 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()

View 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
}

View File

@@ -0,0 +1,4 @@
import { setupWorker } from 'msw/browser'
import { handlers } from './handlers'
export const worker = setupWorker(...handlers)

View 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"`,
},
})
}),
]

View 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%')
})
})

View 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)}%`
}

View 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"]
}

View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View 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"]
}

View 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,
},
},
},
})