feat(flow): 工作台与轨迹优化 + 管理端启用回路 + 本地验签配置修复
build / build-push (push) Successful in 1m30s

- 已办去重: 同一办件一行 + doneCount 累计次数(FlwTaskRepository 查询去重)
- 发起/撤回/重提留痕事件行(状态码 6/7, 无需 DDL); /oapi/flow/progress steps 含撤回/重提步骤
- 管理端新增启用回路: POST /admin/map/enable/{id}、POST /admin/def/enable/{id}; GET /api/flow/def/source 源码查询
- 修复 findEnabledByOrdId 未过滤 status 的隐性缺陷; 新增 DeptLabelSupport 科室标签支撑
- 测试: FlowEngineIT 撤回留痕/已办去重断言、OapiFlowContractIT 撤回步骤断言(94 tests 全过)
- 本地验签修复: 本地 UserBase 无 context-path, yh.open.services 显式覆盖服务目录枚举兜底的 /userbase basePath, 否则出站签名与服务端剥离不对齐 → GetUserInfo 等全部「验签失败」; 生产经 Caddy 挂 /userbase 时删除 services 覆盖
This commit is contained in:
chenxing
2026-09-18 16:56:01 +08:00
parent 434aa9d185
commit 9cc0b06c29
18 changed files with 956 additions and 248 deletions
@@ -17,10 +17,10 @@ import java.awt.GraphicsEnvironment;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Arrays;
import java.util.Map;
/**
@@ -30,8 +30,11 @@ import java.util.Map;
* 而 img 标签带不了 app-sign 请求头,因此《流程中心流程图查看方案.md》§二"不做服务端出图"的
* 边界对外场景作废——本类在服务端把布局 JSON 画成 PNG;前端手绘 SVG 方案仅流程中心自有页面继续用。
* <p>
* 布局:自算拓扑最长路径分层(自上而下),忽略设计器画布坐标(那是全屏编辑器坐标系,直接缩放会失真);
* 节点颜色按运行时状态 done/active/rejected/pending 上色,图例仅 instance 口径绘制。
* 布局(2026-09-17 重构,与前端 flowLayout 同口径):自左向右拓扑分层(最长路径),并行分支组以
* SPLIT 中心线为轴垂直居中(SPLIT↔JOIN 配对按各出边可达集求交、取 BFS 序最早的 JOIN),忽略设计器
* 画布坐标(那是全屏编辑器坐标系,直接缩放会失真);连线为正交折线(右中→中线→左中)。
* 配色与前端示意图一致(蓝审批卡/绿开始/灰结束/橙菱形),节点颜色按运行时状态
* done/active/rejected/pending 上色,图例仅 instance 口径绘制。
* 防御出图:环/孤儿节点/缺失端点均降级为"必出图",不抛渲染异常(外部 img 场景无错误页可跳)。
*/
@Slf4j
@@ -42,26 +45,43 @@ public class DiagramImageService {
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 SLOT_W = 210; // 列槽宽(盒型在槽内水平居中)
private static final int GAP_X = 84; // 列间距(留给正交折线走线)
private static final int ROW_H = 92; // 行槽高(盒型在槽内垂直居中)
private static final int LEGEND_H = 48;
private static final int ARROW = 8;
private static final int ARROW = 9;
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);
/** 各类型盒型(w,h):开始/结束胶囊、审批圆角卡、并行菱形(取外接矩形) */
private static int[] boxOf(String type) {
switch (type == null ? "" : type) {
case FlowDefModel.TYPE_START:
case FlowDefModel.TYPE_END:
return new int[]{116, 44};
case FlowDefModel.TYPE_PARALLEL_SPLIT:
case FlowDefModel.TYPE_PARALLEL_JOIN:
return new int[]{132, 78};
default:
return new int[]{200, 64};
}
}
// —— 配色与前端 FlowDiagram.vue 示意图一致(深色线/高对比文字) ——
private static final Color C_TITLE = new Color(0x17, 0x23, 0x3D);
private static final Color C_LINE = new Color(0x51, 0x5A, 0x6E);
private static final Color C_EDGE_LABEL = new Color(0x51, 0x5A, 0x6E);
private static final Color C_TEXT = new Color(0x17, 0x23, 0x3D);
private static final Color C_SUB = new Color(0x80, 0x86, 0x95);
private static final Color C_APPROVAL_BORDER = new Color(0x2D, 0x8C, 0xF0);
private static final Color C_START_FILL = new Color(0x19, 0xBE, 0x6B);
private static final Color C_END_FILL = new Color(0x80, 0x86, 0x95);
private static final Color C_PARALLEL_FILL = new Color(0xFF, 0xF8, 0xF0);
private static final Color C_PARALLEL_BORDER = new Color(0xFF, 0x99, 0x00);
private static final Color C_PARALLEL_TEXT = new Color(0xE4, 0x6A, 0x0A);
private static final Color C_DONE_BORDER = new Color(0x19, 0xBE, 0x6B);
private static final Color C_ACTIVE_BORDER = new Color(0xED, 0x40, 0x14);
private static final Color C_REJECT_FILL = new Color(0xFF, 0xF1, 0xF0);
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);
private static final Color C_PENDING_BORDER = new Color(0xDC, 0xDE, 0xE2);
/** 出图主入口;instance 口径节点带 state 时自动着色并画图例 */
public byte[] renderPng(FlowModels.DiagramVo vo) {
@@ -92,7 +112,7 @@ public class DiagramImageService {
inDeg.merge(e.getTo(), 1, Integer::sum);
}
}
// 拓扑最长路径分层;环兜底:无入度的从层 0 起算,成环残留节点强制层 0(仍必出图)
// 拓扑最长路径分层(层=列,自左向右);环兜底:无入度的从层 0 起算,成环残留节点强制层 0(仍必出图)
Map<String, Integer> level = new HashMap<>();
List<String> queue = new ArrayList<>();
for (Map.Entry<String, Integer> en : inDeg.entrySet()) {
@@ -116,19 +136,48 @@ public class DiagramImageService {
level.putIfAbsent(n.getId(), 0);
}
// 每层节点居中排布;画布尺寸由层高/最宽层决定
// 初始网格:列 x 由层号决定,列内按定义顺序纵排、整列块以内容区中线居中
Map<Integer, List<FlowModels.NodeVo>> layers = new LinkedHashMap<>();
int maxLevel = 0;
int maxRows = 1;
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);
maxRows = Math.max(maxRows, layer.size());
}
int axisH = maxRows * ROW_H;
Map<String, int[]> box = new HashMap<>();
for (Map.Entry<Integer, List<FlowModels.NodeVo>> en : layers.entrySet()) {
List<FlowModels.NodeVo> layer = en.getValue();
int blockH = layer.size() * ROW_H;
int top0 = (axisH - blockH) / 2;
for (int i = 0; i < layer.size(); i++) {
FlowModels.NodeVo n = layer.get(i);
int[] wh = boxOf(n.getType());
box.put(n.getId(), new int[]{
en.getKey() * (SLOT_W + GAP_X) + (SLOT_W - wh[0]) / 2,
top0 + i * ROW_H + (ROW_H - wh[1]) / 2,
wh[0], wh[1]});
}
}
// 并行分支组以 SPLIT 中心线为轴垂直居中(内层组先处理,最后整体抬回顶部 0)
centerParallelGroups(nodes, out, box);
// 依据实际包围盒定画布尺寸并整体平移进留白
int minX = Integer.MAX_VALUE;
int minY = Integer.MAX_VALUE;
int maxX = Integer.MIN_VALUE;
int maxY = Integer.MIN_VALUE;
for (int[] b : box.values()) {
minX = Math.min(minX, b[0]);
minY = Math.min(minY, b[1]);
maxX = Math.max(maxX, b[0] + b[2]);
maxY = Math.max(maxY, b[1] + b[3]);
}
contentW = Math.max(contentW, NODE_W);
boolean hasState = false;
for (FlowModels.NodeVo n : nodes) {
if (n.getState() != null) {
@@ -136,9 +185,14 @@ public class DiagramImageService {
break;
}
}
int imgW = contentW + MARGIN * 2;
int imgH = MARGIN + TITLE_H + (maxLevel + 1) * NODE_H + maxLevel * LEVEL_GAP_Y
+ MARGIN + (hasState ? LEGEND_H : 0);
int dx = MARGIN - minX;
int dy = MARGIN + TITLE_H - minY;
for (int[] b : box.values()) {
b[0] += dx;
b[1] += dy;
}
int imgW = (maxX - minX) + MARGIN * 2;
int imgH = TITLE_H + (maxY - minY) + MARGIN * 2 + (hasState ? LEGEND_H : 0);
BufferedImage canvas = new BufferedImage(imgW * SCALE, imgH * SCALE, BufferedImage.TYPE_INT_ARGB);
Graphics2D g = canvas.createGraphics();
@@ -149,66 +203,60 @@ public class DiagramImageService {
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 subFont = cjkFont(Font.PLAIN, 12);
Font titleFont = cjkFont(Font.BOLD, 17);
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);
g.drawString(title, (imgW - titleFm.stringWidth(title)) / 2f, MARGIN + titleFont.getSize() + 2);
// 节点坐标
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));
g.setStroke(new BasicStroke(1.6f));
for (FlowModels.EdgeVo e : edges) {
if (!tl.containsKey(e.getFrom()) || !tl.containsKey(e.getTo())) {
int[] from = box.get(e.getFrom());
int[] to = box.get(e.getTo());
if (from == null || to == null) {
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;
int x1 = from[0] + from[2];
int y1 = from[1] + from[3] / 2;
int x2 = to[0];
int y2 = to[1] + to[3] / 2;
int mid = (x1 + x2) / 2;
g.setColor(C_LINE);
g.drawLine(x1, y1, x2, y2);
drawArrowHead(g, x2, y2);
// 多出口分支文案:中点白底小字(单出口连线不标,避免噪音)
g.drawLine(x1, y1, mid, y1);
g.drawLine(mid, y1, mid, y2);
g.drawLine(mid, y2, x2 - ARROW, y2);
drawArrowHead(g, x2 - ARROW, y2);
// 多出口分支文案:折线竖段中点白底小字(单出口连线不标,避免噪音)
if (edgeNames.get(e.getFrom()).size() > 1 && e.getName() != null && !e.getName().isEmpty()) {
drawMidLabel(g, x1, y1, x2, y2, e.getName(), smallFont);
drawMidLabel(g, mid, (y1 + y2) / 2, e.getName(), smallFont);
}
}
for (FlowModels.NodeVo n : nodes) {
drawNode(g, n, tl.get(n.getId()), labelFont, subFont);
drawNode(g, n, box.get(n.getId()), labelFont, subFont);
}
// 图例(仅 instance 口径)
if (hasState) {
g.setFont(smallFont);
FontMetrics sFm = g.getFontMetrics();
int ly = imgH - LEGEND_H + 18;
int ly = imgH - LEGEND_H + 20;
int x = MARGIN;
String[][] legend = {{"active", "在办"}, {"done", "已办"}, {"pending", "未到"}, {"rejected", "被拒绝"}};
for (String[] it : legend) {
g.setColor(fillOf(it[0]));
g.setColor(Color.WHITE);
g.fillRoundRect(x, ly - 10, 18, 12, 6, 6);
g.setColor(borderOf(it[0]));
g.setStroke(new BasicStroke(1.2f));
g.setStroke(new BasicStroke(1.4f));
g.drawRoundRect(x, ly - 10, 18, 12, 6, 6);
g.setColor(C_TEXT);
g.drawString(it[1], x + 24, ly + 2);
@@ -219,88 +267,212 @@ public class DiagramImageService {
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;
/**
* 并行分支组垂直居中:分支不再从 SPLIT 行向下堆叠,而是以 SPLIT 中心线为轴上下对称展开。
* SPLIT↔JOIN 配对沿用编辑器口径(各出边可达集求交,取 BFS 序最早的 PARALLEL_JOIN);
* 组员 = SPLIT 到 JOIN 之间可达的全部节点;JOIN 中线吸附回 SPLIT 中线。
* 内层组先于外层组处理(嵌套并行整体平移不破坏内层居中);结束统一把最顶行抬回 0。
*/
private void centerParallelGroups(List<FlowModels.NodeVo> nodes, Map<String, List<String>> out,
Map<String, int[]> box) {
class Group {
final String split;
final String join;
final List<String> members;
final int maxX;
Group(String split, String join, List<String> members, int maxX) {
this.split = split;
this.join = join;
this.members = members;
this.maxX = maxX;
}
}
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);
String label = truncate(g, n.getLabel(), NODE_W - 20);
g.drawString(label, 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);
// 副标从 label 基线下方一整行处起步,避免与 label 重叠
int firstLineY = p[1] + 12 + lf.getAscent() + sf.getAscent();
for (int i = 0; i < lines.size(); i++) {
g.drawString(lines.get(i), p[0] + 10, firstLineY + i * sf.getHeight());
List<Group> groups = new ArrayList<>();
for (FlowModels.NodeVo split : nodes) {
if (!FlowDefModel.TYPE_PARALLEL_SPLIT.equals(split.getType()) || !box.containsKey(split.getId())) {
continue;
}
String sid = split.getId();
// BFS 序 + 各分支可达集,用于定位配对 JOIN
Map<String, Integer> order = new HashMap<>();
order.put(sid, 0);
List<String> bfs = new ArrayList<>();
bfs.add(sid);
while (!bfs.isEmpty()) {
String cur = bfs.remove(0);
for (String to : out.getOrDefault(cur, new ArrayList<>())) {
if (!order.containsKey(to)) {
order.put(to, order.size());
bfs.add(to);
}
}
}
List<String> heads = out.getOrDefault(sid, new ArrayList<>());
List<java.util.Set<String>> reachSets = new ArrayList<>();
for (String head : heads) {
java.util.Set<String> set = new java.util.HashSet<>();
List<String> q = new ArrayList<>();
q.add(head);
while (!q.isEmpty()) {
String cur = q.remove(0);
if (set.contains(cur)) {
continue;
}
set.add(cur);
q.addAll(out.getOrDefault(cur, new ArrayList<>()));
}
reachSets.add(set);
}
java.util.Set<String> common = reachSets.isEmpty() ? new java.util.HashSet<>() : reachSets.get(0);
for (java.util.Set<String> set : reachSets) {
common = new java.util.HashSet<>(common);
common.retainAll(set);
}
String join = null;
int joinOrder = Integer.MAX_VALUE;
for (FlowModels.NodeVo n : nodes) {
if (FlowDefModel.TYPE_PARALLEL_JOIN.equals(n.getType()) && box.containsKey(n.getId())
&& common.contains(n.getId())) {
int o = order.getOrDefault(n.getId(), Integer.MAX_VALUE);
if (o < joinOrder) {
joinOrder = o;
join = n.getId();
}
}
}
if (join == null) {
continue;
}
// 组员:SPLIT 起步、不穿过 JOIN 的可达集(含嵌套 SPLIT/JOIN)
List<String> members = new ArrayList<>();
java.util.Set<String> seen = new java.util.HashSet<>();
seen.add(join);
List<String> q = new ArrayList<>();
q.add(sid);
while (!q.isEmpty()) {
String cur = q.remove(0);
for (String to : out.getOrDefault(cur, new ArrayList<>())) {
if (seen.contains(to) || !box.containsKey(to)) {
continue;
}
seen.add(to);
members.add(to);
q.add(to);
}
}
int maxLevel = 0;
for (String m : members) {
maxLevel = Math.max(maxLevel, box.get(m)[0]);
}
groups.add(new Group(sid, join, members, maxLevel));
}
// maxX 降序 = 内层组先处理
groups.sort((a, b) -> Integer.compare(b.maxX, a.maxX));
for (Group gp : groups) {
int[] s = box.get(gp.split);
int axisY = s[1] + s[3] / 2;
int top = Integer.MAX_VALUE;
int bottom = Integer.MIN_VALUE;
for (String m : gp.members) {
int[] b = box.get(m);
top = Math.min(top, b[1]);
bottom = Math.max(bottom, b[1] + b[3]);
}
int delta = axisY - (top + bottom) / 2;
for (String m : gp.members) {
box.get(m)[1] += delta;
}
int[] j = box.get(gp.join);
j[1] += axisY - (j[1] + j[3] / 2);
}
// 上移出界(y<0)的组把整图平移回 0(同 delta 平移不破坏各组居中)
int minY = Integer.MAX_VALUE;
for (int[] b : box.values()) {
minY = Math.min(minY, b[1]);
}
if (minY < 0) {
for (int[] b : box.values()) {
b[1] -= minY;
}
}
}
private void drawMidLabel(Graphics2D g, int x1, int y1, int x2, int y2, String s, Font font) {
private void drawNode(Graphics2D g, FlowModels.NodeVo n, int[] p, Font labelF, Font subF) {
String type = n.getType() == null ? "" : n.getType();
if (FlowDefModel.TYPE_START.equals(type) || FlowDefModel.TYPE_END.equals(type)) {
// 开始/结束:实心胶囊(绿/灰)+ 白字
g.setColor(FlowDefModel.TYPE_START.equals(type) ? C_START_FILL : C_END_FILL);
g.fillRoundRect(p[0], p[1], p[2], p[3], p[3], p[3]);
g.setFont(labelF);
g.setColor(Color.WHITE);
FontMetrics fm = g.getFontMetrics();
String label = truncate(g, n.getLabel(), p[2] - 24);
g.drawString(label, p[0] + (p[2] - fm.stringWidth(label)) / 2f,
p[1] + (p[3] - fm.getHeight()) / 2 + fm.getAscent());
return;
}
if (FlowDefModel.TYPE_PARALLEL_SPLIT.equals(type) || FlowDefModel.TYPE_PARALLEL_JOIN.equals(type)) {
// 并行分发/汇聚等待:菱形(橙框橙字,active 红框)
int cx = p[0] + p[2] / 2;
int cy = p[1] + p[3] / 2;
Path2D d = new Path2D.Float();
d.moveTo(cx, p[1]);
d.lineTo(p[0] + p[2], cy);
d.lineTo(cx, p[1] + p[3]);
d.lineTo(p[0], cy);
d.closePath();
g.setColor(C_PARALLEL_FILL);
g.fill(d);
g.setColor("active".equals(n.getState()) ? C_ACTIVE_BORDER : C_PARALLEL_BORDER);
g.setStroke(new BasicStroke("active".equals(n.getState()) ? 2.2f : 1.6f));
g.draw(d);
g.setFont(subF);
g.setColor(C_PARALLEL_TEXT);
FontMetrics fm = g.getFontMetrics();
String label = truncate(g, n.getLabel(), p[2] - 36);
g.drawString(label, cx - fm.stringWidth(label) / 2f, cy + fm.getAscent() / 2f - 1);
return;
}
// 审批:圆角卡(默认蓝框;done 绿/active 红/pending 灰/rejected 红底),label + 单行 subLabel
g.setColor("rejected".equals(n.getState()) ? C_REJECT_FILL : Color.WHITE);
g.fillRoundRect(p[0], p[1], p[2], p[3], 12, 12);
boolean active = "active".equals(n.getState());
g.setColor(borderOf(n));
g.setStroke(new BasicStroke(active ? 2.4f : 1.6f));
g.drawRoundRect(p[0], p[1], p[2], p[3], 12, 12);
g.setFont(labelF);
g.setColor(C_TEXT);
String label = truncate(g, n.getLabel(), p[2] - 20);
g.drawString(label, p[0] + (p[2] - g.getFontMetrics().stringWidth(label)) / 2f, p[1] + 26);
if (n.getSubLabel() != null && !n.getSubLabel().isEmpty()) {
g.setFont(subF);
g.setColor(C_SUB);
String sub = truncate(g, n.getSubLabel(), p[2] - 20);
g.drawString(sub, p[0] + (p[2] - g.getFontMetrics().stringWidth(sub)) / 2f, p[1] + 46);
}
}
private void drawMidLabel(Graphics2D g, int midX, int midY, 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.fillRect(midX - sw / 2 - 4, midY - fm.getAscent() / 2 - 3, sw + 8, fm.getHeight() + 2);
g.setColor(C_EDGE_LABEL);
g.drawString(s, midX - sw / 2f, midY);
g.drawString(s, midX - sw / 2f, midY + fm.getAscent() / 2f - 1);
}
/** 箭头指向 +x(分层自左向右,目标恒在右侧) */
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.moveTo(x + ARROW, y);
p.lineTo(x, y - ARROW / 2.0 + 1);
p.lineTo(x, y + ARROW / 2.0 - 1);
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)) {
@@ -312,17 +484,14 @@ public class DiagramImageService {
if ("rejected".equals(s)) {
return C_REJECT_BORDER;
}
if ("pending".equals(s)) {
return C_PENDING_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);
return C_APPROVAL_BORDER;
}
private Color borderOf(String state) {
@@ -330,11 +499,10 @@ public class DiagramImageService {
case "active": return C_ACTIVE_BORDER;
case "done": return C_DONE_BORDER;
case "rejected": return C_REJECT_BORDER;
default: return C_BORDER_DEFAULT;
default: return C_PENDING_BORDER;
}
}
/**
* 可选指定字体文件(TTF/OTF,支持 .ttc 首个 face):容器/精简 OS 无 CJK 系统字体时的终极兜底,
* 配置路径后 createFont 注册为程序字体并优先命中。空(缺省)走系统字体探测。
@@ -408,35 +576,6 @@ public class DiagramImageService {
return f.canDisplayUpTo(sample) == -1 ? f : null;
}
/** 贪心按像素宽度折行,最多 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 "";