package com.ruoyi.satisfaction.support;
|
|
import com.ruoyi.common.utils.poi.ExcelUtil;
|
import com.ruoyi.satisfaction.domain.ServiceSubtaskDetailRatioExport;
|
import org.apache.poi.ss.usermodel.Workbook;
|
import org.apache.poi.xssf.usermodel.XSSFClientAnchor;
|
import org.apache.poi.xssf.usermodel.XSSFDrawing;
|
import org.apache.poi.xssf.usermodel.XSSFPicture;
|
import org.apache.poi.xssf.usermodel.XSSFSheet;
|
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
|
|
import javax.imageio.ImageIO;
|
import java.awt.BasicStroke;
|
import java.awt.Color;
|
import java.awt.Font;
|
import java.awt.FontMetrics;
|
import java.awt.Graphics2D;
|
import java.awt.RenderingHints;
|
import java.awt.geom.Arc2D;
|
import java.awt.image.BufferedImage;
|
import java.io.ByteArrayOutputStream;
|
import java.util.ArrayList;
|
import java.util.LinkedHashMap;
|
import java.util.List;
|
import java.util.Map;
|
|
/**
|
* 「问题详情占比饼图」Sheet:每道题一个饼图(带百分比标签和图例),
|
* 排版参照纸质报告:每题上方是题目,左边饼图、右边图例,一页 2 列 × 3 行。
|
* <p>
|
* 用 Java2D 画成 PNG 再贴进 Excel,保证「百分比直接标在扇区上」的效果与报告一致
|
* (POI 4.1.2 的原生图表没有数据标签 API)。
|
*
|
* @author smartor
|
*/
|
public class MydOptionRatioPieChartBuilder implements ExcelUtil.ChartSheetBuilder {
|
|
/** Sheet 名称 */
|
public static final String SHEET_NAME = "问题详情占比饼图";
|
|
/** 单张图片宽高(像素) */
|
private static final int IMG_W = 560;
|
private static final int IMG_H = 400;
|
|
/** 每张图占用的列数 / 行数(用于单元格锚点定位) */
|
private static final int COLS_PER_IMG = 9;
|
private static final int ROWS_PER_IMG = 20;
|
|
/** 每行放几张图 */
|
private static final int PER_ROW = 2;
|
|
/** 扇形配色 */
|
private static final Color[] PALETTE = {
|
new Color(0x5B, 0x8F, 0xF9),
|
new Color(0x61, 0xDD, 0xAA),
|
new Color(0xF6, 0xBD, 0x16),
|
new Color(0x72, 0x62, 0xFD),
|
new Color(0x78, 0xD3, 0xF8),
|
new Color(0x96, 0x61, 0xBC),
|
new Color(0xFF, 0x98, 0x4D),
|
new Color(0xFF, 0x6D, 0x6D)
|
};
|
|
private final List<ServiceSubtaskDetailRatioExport> rows;
|
|
private final String footer;
|
|
public MydOptionRatioPieChartBuilder(List<ServiceSubtaskDetailRatioExport> rows, String footer) {
|
this.rows = rows == null ? new ArrayList<>() : rows;
|
this.footer = footer;
|
}
|
|
@Override
|
public void build(XSSFWorkbook workbook) {
|
if (rows.isEmpty()) {
|
return;
|
}
|
XSSFSheet sheet = workbook.createSheet(SHEET_NAME);
|
sheet.setColumnWidth(0, 12 * 256);
|
for (int i = 1; i < PER_ROW * COLS_PER_IMG; i++) {
|
sheet.setColumnWidth(i, 10 * 256);
|
}
|
|
XSSFDrawing drawing = sheet.createDrawingPatriarch();
|
int slot = 0;
|
for (Map.Entry<String, List<ServiceSubtaskDetailRatioExport>> taskEntry : groupByTask().entrySet()) {
|
// 每个任务一行标题
|
int titleRow = (slot / PER_ROW) * ROWS_PER_IMG;
|
org.apache.poi.ss.usermodel.Row tr = sheet.getRow(titleRow) == null
|
? sheet.createRow(titleRow) : sheet.getRow(titleRow);
|
tr.createCell(0).setCellValue("任务名称:" + taskEntry.getKey());
|
|
for (Map.Entry<String, LinkedHashMap<String, Double>> q : groupByQuestion(taskEntry.getValue()).entrySet()) {
|
int col = (slot % PER_ROW) * COLS_PER_IMG;
|
int row = (slot / PER_ROW) * ROWS_PER_IMG + 1;
|
try {
|
byte[] png = renderPie(q.getKey(), q.getValue());
|
int picIndex = workbook.addPicture(png, Workbook.PICTURE_TYPE_PNG);
|
XSSFClientAnchor anchor = new XSSFClientAnchor(0, 0, 0, 0,
|
col, row, col + COLS_PER_IMG - 1, row + ROWS_PER_IMG - 2);
|
XSSFPicture pic = drawing.createPicture(anchor, picIndex);
|
// 尺寸完全由锚点决定(不要 resize(),否则会按 POI 默认列宽重算)
|
} catch (Exception e) {
|
org.apache.poi.ss.usermodel.Row errRow = sheet.getRow(row) == null
|
? sheet.createRow(row) : sheet.getRow(row);
|
errRow.createCell(col).setCellValue(q.getKey() + ":饼图生成失败");
|
}
|
slot++;
|
}
|
slot = ((slot + PER_ROW - 1) / PER_ROW) * PER_ROW;
|
}
|
|
// 页脚:医院--科室
|
int footerRow = ((slot + PER_ROW - 1) / PER_ROW) * ROWS_PER_IMG + 1;
|
org.apache.poi.ss.usermodel.Row fr = sheet.createRow(footerRow);
|
fr.createCell(0).setCellValue(resolveFooter());
|
}
|
|
/**
|
* 按任务名称分组,保持原有顺序
|
*/
|
private Map<String, List<ServiceSubtaskDetailRatioExport>> groupByTask() {
|
Map<String, List<ServiceSubtaskDetailRatioExport>> grouped = new LinkedHashMap<>();
|
for (ServiceSubtaskDetailRatioExport row : rows) {
|
String taskName = row.getTaskname() == null ? "" : row.getTaskname();
|
grouped.computeIfAbsent(taskName, k -> new ArrayList<>()).add(row);
|
}
|
return grouped;
|
}
|
|
/**
|
* 题目 -> (选项 -> 人数),保持出现顺序
|
*/
|
private Map<String, LinkedHashMap<String, Double>> groupByQuestion(List<ServiceSubtaskDetailRatioExport> taskRows) {
|
Map<String, LinkedHashMap<String, Double>> result = new LinkedHashMap<>();
|
for (ServiceSubtaskDetailRatioExport row : taskRows) {
|
String question = row.getQuestiontext() == null ? "" : row.getQuestiontext();
|
String option = row.getOptionresult() == null ? "" : row.getOptionresult();
|
result.computeIfAbsent(question, k -> new LinkedHashMap<>())
|
.put(option, parseCount(row.getCount()));
|
}
|
return result;
|
}
|
|
/**
|
* 页脚:优先用传入值,否则取第一行数据里的科室名称
|
*/
|
private String resolveFooter() {
|
if (footer != null && !footer.trim().isEmpty()) {
|
return footer;
|
}
|
for (ServiceSubtaskDetailRatioExport row : rows) {
|
if (row.getDeptname() != null && !row.getDeptname().trim().isEmpty()) {
|
return row.getDeptname();
|
}
|
if (row.getLeavehospitaldistrictname() != null && !row.getLeavehospitaldistrictname().trim().isEmpty()) {
|
return row.getLeavehospitaldistrictname();
|
}
|
}
|
return "";
|
}
|
|
/**
|
* 画一张饼图:标题 + 饼(扇区内标百分比) + 右侧图例(选项 + 百分比)
|
*/
|
private byte[] renderPie(String question, LinkedHashMap<String, Double> options) throws Exception {
|
BufferedImage img = new BufferedImage(IMG_W, IMG_H, BufferedImage.TYPE_INT_RGB);
|
Graphics2D g = img.createGraphics();
|
g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
|
g.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
|
g.setColor(Color.WHITE);
|
g.fillRect(0, 0, IMG_W, IMG_H);
|
|
// 标题(最多两行)
|
g.setFont(new Font("Microsoft YaHei", Font.BOLD, 18));
|
g.setColor(new Color(0x33, 0x33, 0x33));
|
drawWrapped(g, question, 16, 28, IMG_W - 32);
|
|
double total = 0D;
|
for (Double v : options.values()) {
|
total += v == null ? 0D : v;
|
}
|
|
int cx = 165;
|
int cy = 235;
|
int r = 120;
|
|
if (total <= 0) {
|
g.setFont(new Font("Microsoft YaHei", Font.PLAIN, 16));
|
g.setColor(new Color(0x99, 0x99, 0x99));
|
g.drawString("暂无作答数据", cx - 45, cy);
|
} else {
|
double startAngle = -90D;
|
int i = 0;
|
for (Map.Entry<String, Double> entry : options.entrySet()) {
|
double count = entry.getValue() == null ? 0D : entry.getValue();
|
double angle = count / total * 360D;
|
g.setColor(PALETTE[i % PALETTE.length]);
|
g.fill(new Arc2D.Double(cx - r, cy - r, r * 2D, r * 2D, startAngle, angle, Arc2D.PIE));
|
g.setColor(Color.WHITE);
|
g.setStroke(new BasicStroke(2f));
|
g.draw(new Arc2D.Double(cx - r, cy - r, r * 2D, r * 2D, startAngle, angle, Arc2D.PIE));
|
|
// 扇区中间标百分比
|
String pct = formatPercent(count, total);
|
double mid = Math.toRadians(startAngle + angle / 2D);
|
int tx = (int) (cx + Math.cos(mid) * r * 0.58);
|
int ty = (int) (cy + Math.sin(mid) * r * 0.58);
|
g.setFont(new Font("Microsoft YaHei", Font.BOLD, 15));
|
g.setColor(Color.WHITE);
|
drawCentered(g, pct, tx, ty);
|
|
startAngle += angle;
|
i++;
|
}
|
}
|
|
// 右侧图例
|
g.setFont(new Font("Microsoft YaHei", Font.PLAIN, 14));
|
FontMetrics fm = g.getFontMetrics();
|
int lx = cx + r + 42;
|
int ly = cy - r + 30;
|
int i = 0;
|
for (Map.Entry<String, Double> entry : options.entrySet()) {
|
g.setColor(PALETTE[i % PALETTE.length]);
|
g.fillRect(lx, ly - 10, 12, 12);
|
g.setColor(new Color(0x33, 0x33, 0x33));
|
double count = entry.getValue() == null ? 0D : entry.getValue();
|
String text = entry.getKey() + " " + formatPercent(count, total);
|
g.drawString(text, lx + 18, ly);
|
ly += Math.max(22, fm.getHeight() + 4);
|
i++;
|
}
|
|
g.dispose();
|
ByteArrayOutputStream bos = new ByteArrayOutputStream();
|
ImageIO.write(img, "png", bos);
|
return bos.toByteArray();
|
}
|
|
/**
|
* 百分比:总量为 0 时显示 0.00%
|
*/
|
private String formatPercent(double count, double total) {
|
if (total <= 0) {
|
return "0.00%";
|
}
|
return String.format("%.2f%%", count * 100D / total);
|
}
|
|
/**
|
* 单行居中绘制文本
|
*/
|
private void drawCentered(Graphics2D g, String text, int cx, int cy) {
|
FontMetrics fm = g.getFontMetrics();
|
g.drawString(text, cx - fm.stringWidth(text) / 2, cy + fm.getAscent() / 2 - 2);
|
}
|
|
/**
|
* 按宽度自动换行绘制标题
|
*/
|
private void drawWrapped(Graphics2D g, String text, int x, int y, int maxWidth) {
|
if (text == null) {
|
return;
|
}
|
FontMetrics fm = g.getFontMetrics();
|
StringBuilder line = new StringBuilder();
|
int lineY = y;
|
for (int i = 0; i < text.length(); i++) {
|
char ch = text.charAt(i);
|
if (fm.stringWidth(line.toString() + ch) > maxWidth) {
|
g.drawString(line.toString(), x, lineY);
|
lineY += fm.getHeight();
|
line.setLength(0);
|
}
|
line.append(ch);
|
}
|
if (line.length() > 0) {
|
g.drawString(line.toString(), x, lineY);
|
}
|
}
|
|
/**
|
* 数量字符串 -> 数字
|
*/
|
private double parseCount(String count) {
|
if (count == null || count.trim().isEmpty()) {
|
return 0D;
|
}
|
try {
|
return Double.parseDouble(count.trim());
|
} catch (NumberFormatException e) {
|
return 0D;
|
}
|
}
|
}
|