This commit is contained in:
@@ -0,0 +1,378 @@
|
||||
package com.yunhai.flowcenter.service;
|
||||
|
||||
import com.yunhai.flowcenter.engine.FlowDefModel;
|
||||
import com.yunhai.flowcenter.model.FlowModels;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
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.Path2D;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 流程示意图服务端出图(Java2D,无外部渲染依赖):DiagramVo → PNG 字节流。
|
||||
* <p>
|
||||
* 背景(用户拍板 2026-09-16):外部系统要像嵌 CAS 验证码一样用 {@code <img src>} 直接嵌流程图,
|
||||
* 而 img 标签带不了 app-sign 请求头,因此《流程中心流程图查看方案.md》§二"不做服务端出图"的
|
||||
* 边界对外场景作废——本类在服务端把布局 JSON 画成 PNG;前端手绘 SVG 方案仅流程中心自有页面继续用。
|
||||
* <p>
|
||||
* 布局:自算拓扑最长路径分层(自上而下),忽略设计器画布坐标(那是全屏编辑器坐标系,直接缩放会失真);
|
||||
* 节点颜色按运行时状态 done/active/rejected/pending 上色,图例仅 instance 口径绘制。
|
||||
* 防御出图:环/孤儿节点/缺失端点均降级为"必出图",不抛渲染异常(外部 img 场景无错误页可跳)。
|
||||
*/
|
||||
@Service
|
||||
public class DiagramImageService {
|
||||
|
||||
/** 逻辑坐标常量(输出按 SCALE 倍导出,保证 <img> 缩放清晰) */
|
||||
private static final int SCALE = 2;
|
||||
private static final int MARGIN = 36;
|
||||
private static final int TITLE_H = 56;
|
||||
private static final int NODE_W = 210;
|
||||
private static final int NODE_H = 60;
|
||||
private static final int LEVEL_GAP_Y = 92;
|
||||
private static final int GAP_X = 52;
|
||||
private static final int LEGEND_H = 48;
|
||||
private static final int ARROW = 8;
|
||||
|
||||
private static final Color C_TITLE = new Color(0x33, 0x33, 0x33);
|
||||
private static final Color C_LINE = new Color(0xA6, 0xB1, 0xD4);
|
||||
private static final Color C_EDGE_LABEL = new Color(0x8A, 0x93, 0xB0);
|
||||
private static final Color C_BORDER_DEFAULT = new Color(0xC9, 0xD2, 0xEF);
|
||||
private static final Color C_DONE_BORDER = new Color(0x59, 0x79, 0xEA);
|
||||
private static final Color C_ACTIVE_FILL = new Color(0xFF, 0xF7, 0xE6);
|
||||
private static final Color C_ACTIVE_BORDER = new Color(0xFF, 0x99, 0x00);
|
||||
private static final Color C_REJECT_FILL = new Color(0xFD, 0xEE, 0xEE);
|
||||
private static final Color C_REJECT_BORDER = new Color(0xED, 0x40, 0x14);
|
||||
private static final Color C_PARALLEL_FILL = new Color(0xF8, 0xF8, 0xFF);
|
||||
private static final Color C_PARALLEL_BORDER = new Color(0xA0, 0xA8, 0xC8);
|
||||
private static final Color C_TEXT = new Color(0x33, 0x33, 0x33);
|
||||
private static final Color C_SUB = new Color(0x8A, 0x93, 0xB0);
|
||||
|
||||
/** 出图主入口;instance 口径节点带 state 时自动着色并画图例 */
|
||||
public byte[] renderPng(FlowModels.DiagramVo vo) {
|
||||
BufferedImage img = draw(vo);
|
||||
try (ByteArrayOutputStream out = new ByteArrayOutputStream()) {
|
||||
ImageIO.write(img, "png", out);
|
||||
return out.toByteArray();
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException("流程图渲染失败:" + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
private BufferedImage draw(FlowModels.DiagramVo vo) {
|
||||
List<FlowModels.NodeVo> nodes = vo.getNodes() == null ? new ArrayList<>() : vo.getNodes();
|
||||
List<FlowModels.EdgeVo> edges = vo.getEdges() == null ? new ArrayList<>() : vo.getEdges();
|
||||
Map<String, FlowModels.NodeVo> byId = new HashMap<>();
|
||||
for (FlowModels.NodeVo n : nodes) {
|
||||
byId.put(n.getId(), n);
|
||||
}
|
||||
Map<String, List<String>> out = new HashMap<>();
|
||||
Map<String, Integer> inDeg = new LinkedHashMap<>();
|
||||
for (FlowModels.NodeVo n : nodes) {
|
||||
inDeg.put(n.getId(), 0);
|
||||
}
|
||||
for (FlowModels.EdgeVo e : edges) {
|
||||
if (byId.containsKey(e.getFrom()) && byId.containsKey(e.getTo())) {
|
||||
out.computeIfAbsent(e.getFrom(), k -> new ArrayList<>()).add(e.getTo());
|
||||
inDeg.merge(e.getTo(), 1, Integer::sum);
|
||||
}
|
||||
}
|
||||
// 拓扑最长路径分层;环兜底:无入度的从层 0 起算,成环残留节点强制层 0(仍必出图)
|
||||
Map<String, Integer> level = new HashMap<>();
|
||||
List<String> queue = new ArrayList<>();
|
||||
for (Map.Entry<String, Integer> en : inDeg.entrySet()) {
|
||||
if (en.getValue() == 0) {
|
||||
level.put(en.getKey(), 0);
|
||||
queue.add(en.getKey());
|
||||
}
|
||||
}
|
||||
while (!queue.isEmpty()) {
|
||||
String id = queue.remove(queue.size() - 1);
|
||||
int parent = level.get(id);
|
||||
for (String to : out.getOrDefault(id, new ArrayList<>())) {
|
||||
level.merge(to, parent + 1, Math::max);
|
||||
int d = inDeg.merge(to, -1, Integer::sum);
|
||||
if (d == 0) {
|
||||
queue.add(to);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (FlowModels.NodeVo n : nodes) {
|
||||
level.putIfAbsent(n.getId(), 0);
|
||||
}
|
||||
|
||||
// 每层节点居中排布;画布尺寸由层高/最宽层决定
|
||||
Map<Integer, List<FlowModels.NodeVo>> layers = new LinkedHashMap<>();
|
||||
int maxLevel = 0;
|
||||
for (FlowModels.NodeVo n : nodes) {
|
||||
int l = level.get(n.getId());
|
||||
maxLevel = Math.max(maxLevel, l);
|
||||
layers.computeIfAbsent(l, k -> new ArrayList<>()).add(n);
|
||||
}
|
||||
int contentW = maxLevel * (NODE_W + GAP_X) - GAP_X;
|
||||
for (List<FlowModels.NodeVo> layer : layers.values()) {
|
||||
contentW = Math.max(contentW, layer.size() * (NODE_W + GAP_X) - GAP_X);
|
||||
}
|
||||
contentW = Math.max(contentW, NODE_W);
|
||||
boolean hasState = false;
|
||||
for (FlowModels.NodeVo n : nodes) {
|
||||
if (n.getState() != null) {
|
||||
hasState = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
int imgW = contentW + MARGIN * 2;
|
||||
int imgH = MARGIN + TITLE_H + (maxLevel + 1) * NODE_H + maxLevel * LEVEL_GAP_Y
|
||||
+ MARGIN + (hasState ? LEGEND_H : 0);
|
||||
|
||||
BufferedImage canvas = new BufferedImage(imgW * SCALE, imgH * SCALE, BufferedImage.TYPE_INT_ARGB);
|
||||
Graphics2D g = canvas.createGraphics();
|
||||
g.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
|
||||
g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
|
||||
g.scale(SCALE, SCALE);
|
||||
g.setColor(Color.WHITE);
|
||||
g.fillRect(0, 0, imgW, imgH);
|
||||
|
||||
Font labelFont = cjkFont(Font.BOLD, 14);
|
||||
Font subFont = cjkFont(Font.PLAIN, 11);
|
||||
Font titleFont = cjkFont(Font.BOLD, 16);
|
||||
Font smallFont = cjkFont(Font.PLAIN, 12);
|
||||
|
||||
g.setFont(titleFont);
|
||||
g.setColor(C_TITLE);
|
||||
String title = vo.getName() == null || vo.getName().isEmpty() ? "流程示意图" : vo.getName();
|
||||
FontMetrics titleFm = g.getFontMetrics();
|
||||
g.drawString(title, (imgW - titleFm.stringWidth(title)) / 2f, MARGIN + titleFont.getSize() + 6);
|
||||
|
||||
// 节点坐标
|
||||
Map<String, int[]> tl = new HashMap<>();
|
||||
for (Map.Entry<Integer, List<FlowModels.NodeVo>> en : layers.entrySet()) {
|
||||
List<FlowModels.NodeVo> layer = en.getValue();
|
||||
int rowW = layer.size() * (NODE_W + GAP_X) - GAP_X;
|
||||
int y = MARGIN + TITLE_H + en.getKey() * (NODE_H + LEVEL_GAP_Y);
|
||||
for (int i = 0; i < layer.size(); i++) {
|
||||
tl.put(layer.get(i).getId(), new int[]{(imgW - rowW) / 2 + i * (NODE_W + GAP_X), y});
|
||||
}
|
||||
}
|
||||
|
||||
// 连线(先画线后画节点,节点底色盖住线头)
|
||||
Map<String, Map<String, String>> edgeNames = new HashMap<>();
|
||||
for (FlowModels.EdgeVo e : edges) {
|
||||
edgeNames.computeIfAbsent(e.getFrom(), k -> new HashMap<>()).put(e.getTo(), e.getName());
|
||||
}
|
||||
g.setStroke(new BasicStroke(1.4f));
|
||||
for (FlowModels.EdgeVo e : edges) {
|
||||
if (!tl.containsKey(e.getFrom()) || !tl.containsKey(e.getTo())) {
|
||||
continue;
|
||||
}
|
||||
int[] from = tl.get(e.getFrom());
|
||||
int[] to = tl.get(e.getTo());
|
||||
int x1 = from[0] + NODE_W / 2, y1 = from[1] + NODE_H;
|
||||
int x2 = to[0] + NODE_W / 2, y2 = to[1] - ARROW - 1;
|
||||
g.setColor(C_LINE);
|
||||
g.drawLine(x1, y1, x2, y2);
|
||||
drawArrowHead(g, x2, y2);
|
||||
// 多出口分支文案:中点白底小字(单出口连线不标,避免噪音)
|
||||
if (edgeNames.get(e.getFrom()).size() > 1 && e.getName() != null && !e.getName().isEmpty()) {
|
||||
drawMidLabel(g, x1, y1, x2, y2, e.getName(), smallFont);
|
||||
}
|
||||
}
|
||||
|
||||
for (FlowModels.NodeVo n : nodes) {
|
||||
drawNode(g, n, tl.get(n.getId()), labelFont, subFont);
|
||||
}
|
||||
|
||||
// 图例(仅 instance 口径)
|
||||
if (hasState) {
|
||||
g.setFont(smallFont);
|
||||
FontMetrics sFm = g.getFontMetrics();
|
||||
int ly = imgH - LEGEND_H + 18;
|
||||
int x = MARGIN;
|
||||
String[][] legend = {{"active", "在办"}, {"done", "已办"}, {"pending", "未到"}, {"rejected", "被拒绝"}};
|
||||
for (String[] it : legend) {
|
||||
g.setColor(fillOf(it[0]));
|
||||
g.fillRoundRect(x, ly - 10, 18, 12, 6, 6);
|
||||
g.setColor(borderOf(it[0]));
|
||||
g.setStroke(new BasicStroke(1.2f));
|
||||
g.drawRoundRect(x, ly - 10, 18, 12, 6, 6);
|
||||
g.setColor(C_TEXT);
|
||||
g.drawString(it[1], x + 24, ly + 2);
|
||||
x += 24 + sFm.stringWidth(it[1]) + 24;
|
||||
}
|
||||
}
|
||||
g.dispose();
|
||||
return canvas;
|
||||
}
|
||||
|
||||
private void drawNode(Graphics2D g, FlowModels.NodeVo n, int[] p, Font labelF, Font subF) {
|
||||
String type = n.getType() == null ? "" : n.getType();
|
||||
Color fill = fillOf(n);
|
||||
Color border = borderOf(n);
|
||||
boolean pill = type.equals(FlowDefModel.TYPE_START) || type.equals(FlowDefModel.TYPE_END);
|
||||
if (pill) { // 开始/结束:胶囊
|
||||
g.setColor(fill);
|
||||
g.fillRoundRect(p[0], p[1], NODE_W, NODE_H, NODE_H, NODE_H);
|
||||
g.setColor(border);
|
||||
g.setStroke(new BasicStroke(1.5f));
|
||||
g.drawRoundRect(p[0], p[1], NODE_W, NODE_H, NODE_H, NODE_H);
|
||||
g.setFont(labelF);
|
||||
g.setColor(C_TEXT);
|
||||
FontMetrics fm = g.getFontMetrics();
|
||||
String label = truncate(g, n.getLabel(), NODE_W - 24);
|
||||
g.drawString(label, p[0] + (NODE_W - fm.stringWidth(label)) / 2f,
|
||||
p[1] + (NODE_H - fm.getHeight()) / 2 + fm.getAscent());
|
||||
return;
|
||||
}
|
||||
g.setColor(fill);
|
||||
g.fillRoundRect(p[0], p[1], NODE_W, NODE_H, 12, 12);
|
||||
g.setColor(border);
|
||||
g.setStroke(new BasicStroke("active".equals(n.getState()) ? 2f : 1.4f));
|
||||
g.drawRoundRect(p[0], p[1], NODE_W, NODE_H, 12, 12);
|
||||
// 文本:label 一行 + subLabel 最多两行(截断带 …)
|
||||
FontMetrics lf = g.getFontMetrics(labelF);
|
||||
g.setFont(labelF);
|
||||
g.setColor(C_TEXT);
|
||||
g.drawString(truncate(g, n.getLabel(), NODE_W - 20), p[0] + 10, p[1] + 12 + lf.getAscent());
|
||||
if (n.getSubLabel() != null && !n.getSubLabel().isEmpty()) {
|
||||
g.setFont(subF);
|
||||
g.setColor(C_SUB);
|
||||
FontMetrics sf = g.getFontMetrics(subF);
|
||||
List<String> lines = wrap(g, n.getSubLabel(), NODE_W - 20, 2);
|
||||
int firstLineY = p[1] + 12 + lf.getHeight();
|
||||
for (int i = 0; i < lines.size(); i++) {
|
||||
g.drawString(lines.get(i), p[0] + 10, firstLineY + i * sf.getHeight());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void drawMidLabel(Graphics2D g, int x1, int y1, int x2, int y2, String s, Font font) {
|
||||
g.setFont(font);
|
||||
FontMetrics fm = g.getFontMetrics();
|
||||
int sw = fm.stringWidth(s);
|
||||
int midX = (x1 + x2) / 2, midY = (y1 + y2) / 2;
|
||||
g.setColor(Color.WHITE);
|
||||
g.fillRect(midX - sw / 2 - 4, midY - fm.getAscent(), sw + 8, fm.getHeight());
|
||||
g.setColor(C_EDGE_LABEL);
|
||||
g.drawString(s, midX - sw / 2f, midY);
|
||||
}
|
||||
|
||||
private void drawArrowHead(Graphics2D g, int x, int y) {
|
||||
Path2D p = new Path2D.Float();
|
||||
p.moveTo(x, y + ARROW);
|
||||
p.lineTo(x - ARROW / 2.0, y);
|
||||
p.lineTo(x + ARROW / 2.0, y);
|
||||
p.closePath();
|
||||
g.setColor(C_LINE);
|
||||
g.fill(p);
|
||||
}
|
||||
|
||||
private Color fillOf(FlowModels.NodeVo n) {
|
||||
String s = n.getState();
|
||||
if ("active".equals(s)) {
|
||||
return C_ACTIVE_FILL;
|
||||
}
|
||||
if ("rejected".equals(s)) {
|
||||
return C_REJECT_FILL;
|
||||
}
|
||||
String t = n.getType() == null ? "" : n.getType();
|
||||
if (t.equals(FlowDefModel.TYPE_PARALLEL_SPLIT) || t.equals(FlowDefModel.TYPE_PARALLEL_JOIN)) {
|
||||
return C_PARALLEL_FILL;
|
||||
}
|
||||
if ("done".equals(s)) {
|
||||
return new Color(0xEE, 0xF3, 0xFF);
|
||||
}
|
||||
return Color.WHITE;
|
||||
}
|
||||
|
||||
private Color borderOf(FlowModels.NodeVo n) {
|
||||
String s = n.getState();
|
||||
if ("active".equals(s)) {
|
||||
return C_ACTIVE_BORDER;
|
||||
}
|
||||
if ("done".equals(s)) {
|
||||
return C_DONE_BORDER;
|
||||
}
|
||||
if ("rejected".equals(s)) {
|
||||
return C_REJECT_BORDER;
|
||||
}
|
||||
String t = n.getType() == null ? "" : n.getType();
|
||||
if (t.equals(FlowDefModel.TYPE_PARALLEL_SPLIT) || t.equals(FlowDefModel.TYPE_PARALLEL_JOIN)) {
|
||||
return C_PARALLEL_BORDER;
|
||||
}
|
||||
return C_BORDER_DEFAULT;
|
||||
}
|
||||
|
||||
private Color fillOf(String state) {
|
||||
FlowModels.NodeVo n = new FlowModels.NodeVo();
|
||||
n.setState(state);
|
||||
return fillOf(n);
|
||||
}
|
||||
|
||||
private Color borderOf(String state) {
|
||||
switch (state) {
|
||||
case "active": return C_ACTIVE_BORDER;
|
||||
case "done": return C_DONE_BORDER;
|
||||
case "rejected": return C_REJECT_BORDER;
|
||||
default: return C_BORDER_DEFAULT;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private static Font cjkFont(int style, int size) {
|
||||
return new Font("Microsoft YaHei", style, size);
|
||||
}
|
||||
|
||||
/** 贪心按像素宽度折行,最多 maxLines 行;溢出行以 … 结尾 */
|
||||
private static List<String> wrap(Graphics2D g, String text, int width, int maxLines) {
|
||||
FontMetrics fm = g.getFontMetrics();
|
||||
List<String> lines = new ArrayList<>();
|
||||
int i = 0;
|
||||
while (i < text.length() && lines.size() < maxLines) {
|
||||
StringBuilder line = new StringBuilder();
|
||||
while (i < text.length()
|
||||
&& fm.stringWidth(line + "" + text.charAt(i)) <= width) {
|
||||
line.append(text.charAt(i));
|
||||
i++;
|
||||
}
|
||||
if (line.length() == 0) { // 单字超宽防死循环
|
||||
line.append(text.charAt(i));
|
||||
i++;
|
||||
}
|
||||
lines.add(line.toString());
|
||||
if (i < text.length() && lines.size() == maxLines) {
|
||||
// 溢出截断
|
||||
String last = lines.get(maxLines - 1);
|
||||
while (last.length() > 1 && fm.stringWidth(last + "…") > width) {
|
||||
last = last.substring(0, last.length() - 1);
|
||||
}
|
||||
lines.set(maxLines - 1, last + "…");
|
||||
}
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
private static String truncate(Graphics2D g, String s, int width) {
|
||||
if (s == null || s.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
FontMetrics fm = g.getFontMetrics();
|
||||
if (fm.stringWidth(s) <= width) {
|
||||
return s;
|
||||
}
|
||||
while (s.length() > 1 && fm.stringWidth(s + "…") > width) {
|
||||
s = s.substring(0, s.length() - 1);
|
||||
}
|
||||
return s + "…";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user