filtered = filterProps(props, spec, prefix, warnings);
if (filtered.isEmpty()) {
warnings.add(prefix + "组件「" + displayName(spec)
+ "」的属性都不在清单声明范围内,已忽略");
continue;
}
warnMissingRequired(filtered, spec, prefix, warnings);
QcReportAiDraftRespVO.DraftComponent draft = new QcReportAiDraftRespVO.DraftComponent();
draft.setType(type);
draft.setProps(filtered);
kept.add(draft);
}
return new Result(kept, warnings);
}
/**
* 合并多次调用的结果并去重,最后按上限截断。
*
* 去重是多页扫描件的必需品:页眉、报告标题、表头在每一页都会出现,逐页识别就会得到 N 份,
* 不去重的话画布上会有 5 个一样的抬头。判定同一是
* {@code (type, props 的稳定 JSON)}——props 先按 key 排序,因为不同页返回的 key 顺序
* 不保证一致,直接比原始顺序会把同一个组件当成两个。
*
* @param perCallResults 各次调用归一后的结果,按执行顺序
* @param maxComponents 组件总数上限,超出截断
*/
public static Result mergeAndDedup(List perCallResults, int maxComponents) {
List merged = new ArrayList<>();
Set seen = new LinkedHashSet<>();
for (Result result : perCallResults) {
for (QcReportAiDraftRespVO.DraftComponent component : result.components()) {
if (seen.add(dedupKey(component))) {
merged.add(component);
}
}
}
int total = merged.size();
List warnings = new ArrayList<>();
for (Result result : perCallResults) {
warnings.addAll(result.warnings());
}
if (perCallResults.size() > 1 && total < countRaw(perCallResults)) {
warnings.add(StrUtil.format(
"多页识别共得到 {} 项组件,其中有 {} 项在其它页已出现(页眉、标题、表头等重复内容),已合并",
countRaw(perCallResults), countRaw(perCallResults) - total));
}
if (maxComponents > 0 && total > maxComponents) {
warnings.add(StrUtil.format("识别出的组件共 {} 项,超过单次上限 {} 项,已保留前 {} 项,其余请手工补充",
total, maxComponents, maxComponents));
merged = new ArrayList<>(merged.subList(0, maxComponents));
}
return new Result(merged, warnings);
}
private static int countRaw(List results) {
return results.stream().mapToInt(r -> r.components().size()).sum();
}
/**
* 丢掉清单未声明的 prop key。
*
* {@code fields} 为 null(清单压根没声明)与为 空列表(明确声明「这个组件不吃任何属性」)
* 是两回事:前者无从判断,原样放行;后者才是真的要一个个丢掉。
* 混为一谈会在前端漏传 fields 时把用户识别出来的属性静默抹平。
*/
private static Map filterProps(Map props, QcReportComponentSpecVO spec,
String prefix, List warnings) {
if (spec.getFields() == null) {
return props;
}
Set allowed = new LinkedHashSet<>();
for (QcReportComponentSpecVO.Field field : spec.getFields()) {
if (field != null && StrUtil.isNotBlank(field.getKey())) {
allowed.add(field.getKey());
}
}
Map filtered = new TreeMap<>();
List dropped = new ArrayList<>();
for (Map.Entry entry : props.entrySet()) {
if (allowed.contains(entry.getKey())) {
filtered.put(entry.getKey(), entry.getValue());
} else {
dropped.add(entry.getKey());
}
}
if (!dropped.isEmpty()) {
warnings.add(prefix + "组件「" + displayName(spec) + "」中不属于该组件的属性 "
+ String.join("、", dropped) + " 已丢弃");
}
return filtered;
}
private static void warnMissingRequired(Map props, QcReportComponentSpecVO spec,
String prefix, List warnings) {
if (spec.getFields() == null) {
return;
}
for (QcReportComponentSpecVO.Field field : spec.getFields()) {
if (field == null || !Boolean.TRUE.equals(field.getRequired())) {
continue;
}
if (isBlankValue(props.get(field.getKey()))) {
warnings.add(prefix + "组件「" + displayName(spec) + "」缺少必填属性「"
+ fieldLabel(field) + "」,请在设计器属性面板中补上后再保存");
}
}
}
private static boolean isBlankValue(Object value) {
if (value == null) {
return true;
}
if (value instanceof CharSequence cs) {
return StrUtil.isBlank(cs.toString());
}
return value instanceof Collection> collection && collection.isEmpty();
}
/**
* 去重键:type + props 的稳定序列化。
*
* props 先按 key 排序再用 {@link TreeMap},保证「同样的组件、不同的 key 顺序」也能判为同一个。
*/
private static String dedupKey(QcReportAiDraftRespVO.DraftComponent component) {
Map props = component.getProps();
return component.getType() + " " + JSONUtil.toJsonStr(props == null ? Map.of() : new TreeMap<>(props));
}
private static String displayName(QcReportComponentSpecVO spec) {
return StrUtil.isNotBlank(spec.getLabel()) ? spec.getLabel() : spec.getType();
}
private static String fieldLabel(QcReportComponentSpecVO.Field field) {
return StrUtil.isNotBlank(field.getLabel()) ? field.getLabel() : field.getKey();
}
}