From 443e22d62dad719a702b8cc873c8aa54711f72d2 Mon Sep 17 00:00:00 2001 From: hongqiwei <1143662660@qq.com> Date: Wed, 9 Sep 2026 14:56:24 +0800 Subject: [PATCH] =?UTF-8?q?=E9=97=AE=E9=A2=98=E6=B4=BB=E5=8A=A8=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E7=9C=8B=E6=9D=BF=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/CulturalSportsBoardService.java | 5 +- .../impl/CulturalSportsBoardServiceImpl.java | 116 +++++++----------- .../platform/databoard/culturalSports.html | 42 +++++-- 3 files changed, 82 insertions(+), 81 deletions(-) diff --git a/src/main/java/io/v/nutz/databoard/service/CulturalSportsBoardService.java b/src/main/java/io/v/nutz/databoard/service/CulturalSportsBoardService.java index 7082125..e47459c 100644 --- a/src/main/java/io/v/nutz/databoard/service/CulturalSportsBoardService.java +++ b/src/main/java/io/v/nutz/databoard/service/CulturalSportsBoardService.java @@ -19,8 +19,9 @@ public interface CulturalSportsBoardService { * @param activityId 带来源前缀的活动键,空表示全部活动;必须与 source 对应 * @return summary 核心指标,monthly 月度人次,semesters 半年对比,units 单位明细,unions 工会分组人数及参与率, * composition/ages/titles/genders 各维度人数与比例,notes 为统计口径说明。 - * 参与率为 0—100 的百分数;分母为零或报名范围无法确认时 rate 为 null;范围不完整时 total/populationCount 也为 null。 - * summary.rateReason 解释缺测原因;有效报名人数不受当前人员状态限制。 + * 参与率为 0—100 的百分数;分母为零时 rate 为 null。 + * 分母为活动名单(缺失时用当前在职人员)与有效报名人员并集,按人员 ID 跨活动去重。 + * fallbackActivities 列出采用默认在职范围的活动,notes 说明统计口径。 */ NutMap statistics(int year, String period, String source, String unitId, String activityId); } diff --git a/src/main/java/io/v/nutz/databoard/service/impl/CulturalSportsBoardServiceImpl.java b/src/main/java/io/v/nutz/databoard/service/impl/CulturalSportsBoardServiceImpl.java index 804a739..7b803e9 100644 --- a/src/main/java/io/v/nutz/databoard/service/impl/CulturalSportsBoardServiceImpl.java +++ b/src/main/java/io/v/nutz/databoard/service/impl/CulturalSportsBoardServiceImpl.java @@ -33,7 +33,7 @@ public class CulturalSportsBoardServiceImpl implements CulturalSportsBoardServic @Inject private Dao dao; - private static final String SCOPE_LABEL = "全部有效报名人员"; + private static final String SCOPE_LABEL = "活动范围与有效报名人员合并去重"; private static final String UNKNOWN = "unknown"; /* 校级活动创建时会同步同 ID 到 activity_tissue;以 activity_school 为准,避免场次翻倍。 @@ -49,7 +49,7 @@ public class CulturalSportsBoardServiceImpl implements CulturalSportsBoardServic SELECT CONCAT('activity:',s.id), s.id, 'sports', s.startTime, s.name, s.activityGroupId FROM activity_school s WHERE s.isSave=0 UNION ALL - SELECT CONCAT('training:',t.id), t.id, 'training', t.activityStartTime, t.activityName, NULL + SELECT CONCAT('training:',t.id), t.id, 'training', t.activityStartTime, t.activityName, t.activityGroupId FROM train_sign_up_activity t UNION ALL SELECT CONCAT('family:',f.id), f.id, 'family', f.activityStartTime, f.activityName, f.activityGroupId @@ -57,9 +57,9 @@ public class CulturalSportsBoardServiceImpl implements CulturalSportsBoardServic """; /* 档案只补充归属和人员构成,不再用当前状态、禁用或删除标记抹去历史有效报名。 - 报名资格分母另取活动范围;当前档案不能冒充历史资格快照。 */ + 分母优先取活动名单,名单缺失时用当前在职人员兜底,再补入有效报名人员。 */ private static final String PEOPLE_SQL = """ - SELECT u.id, u.unitid, unit.name unit_name, unit.unionid unionid, un.unionname union_name, u.birthday, u.sex, u.personType person_type, u.jobTitle job_title + SELECT u.id, u.userState user_state, u.unitid, unit.name unit_name, unit.unionid unionid, un.unionname union_name, u.birthday, u.sex, u.personType person_type, u.jobTitle job_title FROM sys_user u LEFT JOIN sys_unit unit ON unit.id=u.unitid LEFT JOIN sys_union un ON un.id=unit.unionid """; @@ -191,12 +191,12 @@ public class CulturalSportsBoardServiceImpl implements CulturalSportsBoardServic } } - // 每个活动先核对自身范围,禁止用其他活动的名单掩盖越界报名;多活动分母取并集。 + // 只查询所选周期内活动引用的人员名单,四类活动均按实际范围 ID 关联。 + List selectedEvents = events.values().stream() + .filter(event -> inPeriod(event.getInt("month"), period)).collect(Collectors.toList()); + List scopeIds = selectedEvents.stream().map(event -> text(event.getString("scope_id"))) + .filter(id -> !id.isEmpty()).distinct().collect(Collectors.toList()); Map> scopes = new HashMap<>(); - List scopeIds = events.values().stream() - .filter(event -> inPeriod(event.getInt("month"), period)) - .map(event -> text(event.getString("scope_id"))).filter(id -> !id.isEmpty()).distinct().collect(Collectors.toList()); - // 只读取当前活动引用的名单,避免每次筛选加载全部历史范围;范围 ID 均参数化。 if (!scopeIds.isEmpty()) { List parameters = new ArrayList<>(); for (int index = 0; index < scopeIds.size(); index++) parameters.add("@scope" + index); @@ -208,36 +208,28 @@ public class CulturalSportsBoardServiceImpl implements CulturalSportsBoardServic if (!id.isEmpty()) scopes.computeIfAbsent(text(row.getString("scope_id")), key -> new HashSet<>()).add(id); } } - Set eligible = new HashSet<>(); - Set rateReasons = new java.util.LinkedHashSet<>(); - if (activities == 0) rateReasons.add("当前筛选范围没有已开展活动,无法确定报名范围"); - for (Map.Entry entry : events.entrySet()) { - NutMap event = entry.getValue(); - if (!inPeriod(event.getInt("month"), period)) continue; - if ("training".equals(event.getString("source"))) { - rateReasons.add("品牌活动未保存完整报名资格名单,不能用当前档案推算历史分母"); - continue; - } + // 无范围编号或名单无有效人员时,按项目“在职”状态兜底,不推断为历史在职快照。 + Set currentStaff = people.entrySet().stream() + .filter(entry -> "在职".equals(entry.getValue().getString("user_state"))) + .map(Map.Entry::getKey).collect(Collectors.toSet()); + Set population = new HashSet<>(); + List fallbackActivities = new ArrayList<>(); + for (NutMap event : selectedEvents) { Set scope = scopes.get(text(event.getString("scope_id"))); if (scope == null || scope.isEmpty()) { - rateReasons.add("部分活动的人员范围未配置或名单已缺失"); - continue; - } - if (!scope.containsAll(eventPeople.getOrDefault(entry.getKey(), java.util.Collections.emptySet()))) { - rateReasons.add("部分有效报名人员不在现存活动名单中,无法确认完整历史范围"); + scope = currentStaff; + fallbackActivities.add(row(event.getString("event_key"), event.getString("name")) + .setv("source", event.getString("source"))); } for (String id : scope) { NutMap person = people.computeIfAbsent(id, key -> new NutMap().setv("id", key).setv("missing_profile", true)); - if (!selectedUnit.isEmpty() && person.getBoolean("missing_profile", false)) { - rateReasons.add("活动范围存在档案缺失人员,无法确定其所属单位"); - } - if (selectedUnit.isEmpty() || selectedUnit.equals(unitKey(person))) eligible.add(id); + if (selectedUnit.isEmpty() || selectedUnit.equals(unitKey(person))) population.add(id); } } - if (unidentifiedRecords > 0) rateReasons.add("存在缺少人员 ID 的报名记录,无法完整核对参与人数"); - boolean rateAvailable = rateReasons.isEmpty(); - Set displayedPeople = new HashSet<>(selectedPeople); - if (rateAvailable) displayedPeople.addAll(eligible); + // 范围外有效报名补入分母;跨活动取并集,每名人员只计一次。 + population.addAll(selectedPeople); + boolean rateAvailable = !population.isEmpty(); + Set displayedPeople = population; Map personTypes = dictionary("UserType"); Map titleNames = dictionary("JobTitle"); @@ -245,7 +237,7 @@ public class CulturalSportsBoardServiceImpl implements CulturalSportsBoardServic Map unions = new LinkedHashMap<>(); Map composition = new LinkedHashMap<>(); Map ages = buckets("30岁及以下", "31—40岁", "41—50岁", "51岁及以上", "年龄未知"); - Map titles = buckets("正高", "副高", "中级", "初级", "其他职称", "未填职称"); + Map titles = new LinkedHashMap<>(); Map genders = buckets("男", "女", "未知"); LocalDate ageDate = "first".equals(period) ? LocalDate.of(year, 6, 30) : yearEnd; if (ageDate.isAfter(today)) { @@ -254,7 +246,7 @@ public class CulturalSportsBoardServiceImpl implements CulturalSportsBoardServic for (String id : displayedPeople) { NutMap person = people.get(id); boolean joined = selectedPeople.contains(id); - boolean inScope = rateAvailable && eligible.contains(id); + boolean inScope = population.contains(id); add(units, unitKey(person), unitName(person), joined, inScope); // 与项目 user 视图一致:工会由人员所属单位的 unionid 确定;分子分母使用同一归属。 String unionId = text(person.getString("unionid")); @@ -267,7 +259,9 @@ public class CulturalSportsBoardServiceImpl implements CulturalSportsBoardServic String age = ageGroup(person.getString("birthday"), ageDate); add(ages, age, age, joined, inScope); String jobTitle = text(person.getString("job_title")); - String title = titleGroup(titleNames.getOrDefault(jobTitle, jobTitle)); + // 按档案具体职称统计,字典代码转为名称;保留专业括注,不再合并职称等级。 + String title = jobTitle.isEmpty() ? "未填职称" : text(titleNames.getOrDefault(jobTitle, jobTitle)); + if (title.isEmpty()) title = jobTitle; add(titles, title, title, joined, inScope); String sex = text(person.getString("sex")); sex = "男".equals(sex) || "女".equals(sex) ? sex : "未知"; @@ -280,6 +274,11 @@ public class CulturalSportsBoardServiceImpl implements CulturalSportsBoardServic List unionRows = rows(unions, selectedPeople.size(), rateAvailable); unionRows.sort(Comparator.comparingDouble(v -> v.getDouble("rate", -1)).reversed() .thenComparing(v -> v.getString("name"))); + // 所有范围内职称均保留(包括零参与);未填职称固定最后,其余按范围人数降序、同人数按名称排序。 + List titleRows = rows(titles, selectedPeople.size(), rateAvailable); + titleRows.sort(Comparator.comparing(v -> "未填职称".equals(v.getString("name"))) + .thenComparing(Comparator.comparingInt(v -> v.getInt("total")).reversed()) + .thenComparing(v -> v.getString("name"))); List monthRows = new ArrayList<>(); for (int month = 1; month <= 12; month++) { if (inPeriod(month, period)) { @@ -288,7 +287,7 @@ public class CulturalSportsBoardServiceImpl implements CulturalSportsBoardServic monthRows.add(new NutMap().setv("name", month + "月").setv("value", value)); } } - Integer total = rateAvailable ? eligible.size() : null; + int total = population.size(); // 页面已移除半年参与率;保留半年场次和人数,避免以当前周期分母推算另一周期。 List semesters = Arrays.asList( semester("上半年", firstActivities, firstPeople.size(), 0, false), @@ -298,26 +297,27 @@ public class CulturalSportsBoardServiceImpl implements CulturalSportsBoardServic "参与口径:有效报名/登记,不等同于实际到场;同一人同一活动的多个项目或课程只计一人次。", "亲子活动按正常报名状态(1、3)的教职工去重,家属不计入教职工人数及参与率。", "参与人数包含全部有效报名人员,不按当前在职状态、账号禁用或档案删除标记排除。", - "参与率=同类去重参与人数÷现存活动范围内同类去重人数;人员构成、年龄分层、性别饼图展示参与人员占比。", + "参与率=同类有效报名去重人数÷同类统计范围去重人数;人员构成、年龄分层、性别饼图展示参与人员占比。", "按活动开始日期统计已开展活动;同 ID 校级与文化活动合并归属“体育活动”,全年人数跨半年去重。", "工会按人员当前所属工会统计;年龄计算截至 " + ageDate + "。")); if (!selectedUnit.isEmpty()) { notes.add("单位按当前人员所属单位统计;场次仅计入本单位人员有有效报名的活动。"); } - notes.add("活动范围按现存名单核对,多活动取人员并集;无法确认完整范围时参与率显示“—”,人数照常统计。"); - notes.addAll(rateReasons); + notes.add("分母为所选活动范围名单与有效报名人员的并集;缺少范围名单时用当前 userState=在职 的人员兜底,历史年度同样使用当前在职档案。单位筛选同步限定分子分母。"); + for (NutMap activity : fallbackActivities) notes.add("默认范围:" + activity.getString("name") + ",范围未配置或名单为空,使用当前在职教职工并补入有效报名人员。"); long missingProfiles = selectedPeople.stream().filter(id -> people.get(id).getBoolean("missing_profile", false)).count(); if (missingProfiles > 0) notes.add("本期 " + missingProfiles + " 名报名人员无法关联档案,已计入人数,归属和人员属性列为未知。"); if (unidentifiedRecords > 0) notes.add("本期存在 " + unidentifiedRecords + " 条缺少人员 ID 的报名查询记录,无法可靠去重,未并入人数。"); return new NutMap().setv("summary", new NutMap().setv("activityCount", activities) .setv("visitCount", visits).setv("participantCount", selectedPeople.size()) - .setv("populationCount", total).setv("rate", rateAvailable ? rate(selectedPeople.size(), total) : null) - .setv("rateAvailable", rateAvailable).setv("rateReason", String.join(";", rateReasons)) + .setv("populationCount", total).setv("rate", rate(selectedPeople.size(), total)) + .setv("rateAvailable", rateAvailable).setv("rateReason", rateAvailable ? "" : "当前统计范围人数为零") .setv("firstActivityCount", firstActivities).setv("secondActivityCount", secondActivities)) .setv("monthly", monthRows).setv("semesters", semesters).setv("units", unitRows).setv("unions", unionRows) .setv("composition", rows(composition, selectedPeople.size(), rateAvailable)) - .setv("ages", rows(ages, selectedPeople.size(), rateAvailable)).setv("titles", rows(titles, selectedPeople.size(), rateAvailable)) + .setv("ages", rows(ages, selectedPeople.size(), rateAvailable)).setv("titles", titleRows) .setv("genders", rows(genders, selectedPeople.size(), rateAvailable)).setv("notes", notes) + .setv("fallbackActivities", fallbackActivities) .setv("periodName", periodName).setv("scopeLabel", SCOPE_LABEL) .setv("asOf", cutoff.toString()).setv("year", year); } @@ -370,31 +370,6 @@ public class CulturalSportsBoardServiceImpl implements CulturalSportsBoardServic : age <= 50 ? "41—50岁" : "51岁及以上"; } - /** 只删除专业系列括注,不使用 contains("教授") 等模糊匹配,防止副教授错归正高。 */ - private String titleGroup(String name) { - String title = text(name).replaceAll("[((].*?[))]", "").trim(); - if (title.isEmpty()) { - return "未填职称"; - } - if (Arrays.asList("正高", "正高级", "教授", "研究员", "研究馆员", "正高级工程师", "教授级高级工程师", - "主任医师", "主任护师", "主任药师", "主任技师", "正高级实验师", "正高级会计师").contains(title)) { - return "正高"; - } - if (Arrays.asList("副高", "副高级", "副教授", "副研究员", "副研究馆员", "高级工程师", "高级实验师", - "高级会计师", "高级经济师", "副主任医师", "副主任护师", "副主任药师", "副主任技师").contains(title)) { - return "副高"; - } - if (Arrays.asList("中级", "讲师", "助理研究员", "工程师", "实验师", "馆员", "会计师", "经济师", - "主治医师", "主管护师", "主管药师", "主管技师", "审计师").contains(title)) { - return "中级"; - } - if (Arrays.asList("初级", "助教", "研究实习员", "助理工程师", "助理实验师", "助理馆员", "助理会计师", - "助理经济师", "技术员", "医师", "护师", "药师", "技师").contains(title)) { - return "初级"; - } - return "其他职称"; - } - /** 兼容库内日期、年月及活动日期时间;非法日期不补成当前日期。 */ private LocalDate parseDate(String value) { String date = text(value).replace('/', '-'); @@ -429,6 +404,7 @@ public class CulturalSportsBoardServiceImpl implements CulturalSportsBoardServic return result; } + /** 分组累计范围总人数与有效报名人数;报名人员已补入范围,参与率不会超出 100%。 */ private void add(Map groups, String key, String name, boolean joined, boolean inScope) { Bucket bucket = groups.computeIfAbsent(key, item -> new Bucket(name)); if (inScope) bucket.total++; @@ -439,8 +415,8 @@ public class CulturalSportsBoardServiceImpl implements CulturalSportsBoardServic private List rows(Map groups, int participantTotal, boolean rateAvailable) { List result = new ArrayList<>(); - groups.forEach((id, bucket) -> result.add(row(id, bucket.name).setv("total", rateAvailable ? bucket.total : null) - .setv("participants", bucket.participants).setv("rate", rateAvailable ? rate(bucket.participants, bucket.total) : null) + groups.forEach((id, bucket) -> result.add(row(id, bucket.name).setv("total", bucket.total) + .setv("participants", bucket.participants).setv("rate", rate(bucket.participants, bucket.total)) .setv("share", rate(bucket.participants, participantTotal)))); return result; } @@ -463,7 +439,7 @@ public class CulturalSportsBoardServiceImpl implements CulturalSportsBoardServic return result; } - /** 分子累计全部有效报名,分母累计已核对范围;范围不完整时只返回人数和占比。 */ + /** 参与人数用于构成占比,并与合并范围总人数计算参与率。 */ private static class Bucket { private final String name; private int total; diff --git a/src/main/resources/views/platform/databoard/culturalSports.html b/src/main/resources/views/platform/databoard/culturalSports.html index dbb5a85..eba82de 100644 --- a/src/main/resources/views/platform/databoard/culturalSports.html +++ b/src/main/resources/views/platform/databoard/culturalSports.html @@ -159,7 +159,7 @@ layout("/layouts/platform.html"){
-
整体参与率
报名范围人数
{{ number(summary.populationCount) }} 人
+
整体参与率
统计范围人数
{{ number(summary.populationCount) }} 人
@@ -173,11 +173,18 @@ layout("/layouts/platform.html"){
+ +
+

职称参与率

同职称参与人数 / 同职称统计范围人数
+ +
具体职称按统计范围人数从多到少排列,未填职称固定最后;可拖动底部滑块查看全部职称。虚线为整体参与率。
+

人员构成

参与人员占比
@@ -200,7 +207,7 @@ layout("/layouts/platform.html"){ - +
@@ -244,7 +251,7 @@ layout("/layouts/platform.html"){ board: null, unitPage: {number: 1, size: 8}, charts: { - monthly: emptyChart('加载后展示月度趋势'), unions: emptyChart(), composition: emptyChart(), + monthly: emptyChart('加载后展示月度趋势'), titles: emptyChart(), composition: emptyChart(), ages: emptyChart(), genders: emptyChart() } }; @@ -270,7 +277,7 @@ layout("/layouts/platform.html"){ const summary = this.summary; if (!summary.activityCount) return '当前筛选范围暂无已开展活动,可切换年度、来源或单位查看。'; let result = this.board.year + '年' + this.periodName + '开展 ' + summary.activityCount + ' 场活动,覆盖 ' + summary.participantCount + ' 人。'; - result += summary.rate == null ? ' 参与率暂不计算:' + (summary.rateReason || '报名范围人数为零') + '。' : ' 整体参与率 ' + this.percent(summary.rate) + '。'; + result += summary.rate == null ? ' 参与率暂不计算:' + (summary.rateReason || '统计范围人数为零') + '。' : ' 整体参与率 ' + this.percent(summary.rate) + '。'; return result; } }, @@ -364,7 +371,7 @@ layout("/layouts/platform.html"){ tooltip: {trigger: 'axis', confine: true, renderMode: 'richText', formatter: (items) => { const row = rows[items[0].dataIndex]; return row.name + '\n' + (field === 'share' ? '参与人员占比:' : '参与率:') + this.percent(row[field]) - + '\n参与人数:' + row.participants + (field === 'rate' ? '\n人员总数:' + row.total : ''); + + '\n参与人数:' + row.participants + (field === 'rate' ? '\n统计范围人数:' + row.total : ''); }}, xAxis: horizontal ? value : category, yAxis: horizontal ? category : value, series: [{type: 'bar', barMaxWidth: horizontal ? 17 : 34, data: rows.map((row, index) => ({value: row[field], itemStyle: {color: horizontal ? palette[index % palette.length] : palette[0]}})), @@ -382,7 +389,7 @@ layout("/layouts/platform.html"){ tooltip: {trigger: 'item', confine: true, renderMode: 'richText', formatter: (item) => { const row = participants[item.dataIndex]; return row.name + '\n参与人数:' + row.participants + '\n参与人员占比:' + this.percent(row.share) - + '\n该性别参与率:' + this.percent(row.rate) + (row.total == null ? '(报名范围无法确认)' : ''); + + '\n统计范围人数:' + row.total + '\n该性别参与率:' + this.percent(row.rate); }}, legend: {bottom: 0, itemWidth: 8, itemHeight: 8, textStyle: {color: '#63758e', fontSize: 11}}, series: [{type: 'pie', radius: '70%', center: ['50%', '42%'], @@ -396,8 +403,9 @@ layout("/layouts/platform.html"){ /** 每次聚合结果整体替换图表配置;v-chart 自行处理窗口缩放和组件销毁。 */ buildCharts() { const board = this.board; + /* 工会图表配置暂时停用,保留以便恢复。 const unionChart = this.barChart(board.unions, 'rate', false); - if (!board.summary.rateAvailable) unionChart.title.text = '报名范围无法确认,参与率暂不计算'; + if (!board.summary.rateAvailable) unionChart.title.text = '当前统计范围人数为零,参与率暂不计算'; if (unionChart.grid) { unionChart.grid.bottom = 66; unionChart.xAxis.axisLabel.formatter = (label) => label.length > 7 ? label.slice(0, 7) + '…' : label; @@ -408,6 +416,21 @@ layout("/layouts/platform.html"){ label: {position: 'insideEndTop', formatter: '整体 ' + this.percent(board.summary.rate), color: '#638bc9', fontSize: 10}, data: [{yAxis: board.summary.rate}]}; } } + */ + // 复用后端职称分组及当前统计范围分母,缺测值保留为空,不转换成零参与率。 + const titleChart = this.barChart(board.titles, 'rate', false); + // 职称不截取排名;通过滑块浏览全部分类,悬浮提示显示完整名称和分子分母。 + if (titleChart.grid && board.titles.length > 8) { + titleChart.grid.bottom = 66; + titleChart.xAxis.axisLabel.formatter = (label) => label.length > 7 ? label.slice(0, 7) + '…' : label; + titleChart.dataZoom = [{type: 'slider', height: 13, bottom: 9, startValue: 0, endValue: 7, + borderColor: '#e3ebf8', fillerColor: '#dceaff', handleStyle: {color: '#7faff9'}, showDetail: false}]; + } + if (titleChart.grid && board.summary.rate != null) { + titleChart.series[0].markLine = {silent: true, symbol: 'none', lineStyle: {color: '#82a9f5', type: 'dashed'}, + label: {position: 'insideEndTop', formatter: '整体 ' + this.percent(board.summary.rate), color: '#638bc9', fontSize: 10}, + data: [{yAxis: board.summary.rate}]}; + } const composition = board.composition.filter((row) => row.participants > 0); const pie = composition.length ? { color: palette, @@ -428,7 +451,8 @@ layout("/layouts/platform.html"){ lineStyle: {width: 2, color: '#2674f5'}, itemStyle: {color: '#2674f5'}, areaStyle: {color: '#e2edff'}, connectNulls: false}] }; this.charts = { - monthly: months, unions: unionChart, composition: pie, + // 恢复工会图表时使用 unions: unionChart,并恢复上方模板和配置。 + monthly: months, titles: titleChart, composition: pie, ages: this.barChart(board.ages.filter((row) => row.participants > 0), 'share', true), genders: this.genderPie(board.genders) };