Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ public interface SettingConfigGetter {
class BasicsConfig {
public static final String GROUP = "basics";
private String title;
private Boolean enableMomentHeatmap;
}
@Data
class UmamiConfig {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@
import java.time.Instant;
import java.time.LocalDate;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;
import java.util.stream.Stream;

Expand All @@ -18,25 +20,35 @@
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.xhhao.dataStatistics.common.Constants;
import com.xhhao.dataStatistics.service.SettingConfigGetter;
import com.xhhao.dataStatistics.service.StatisticalService;
import com.xhhao.dataStatistics.vo.PieChartVO;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import run.halo.app.core.extension.content.Category;
import run.halo.app.core.extension.content.Comment;
import run.halo.app.core.extension.content.Post;
import run.halo.app.core.extension.content.Tag;
import run.halo.app.extension.GroupVersionKind;
import run.halo.app.extension.ListOptions;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.extension.Unstructured;
import run.halo.app.extension.index.query.Queries;
import run.halo.app.extension.router.selector.FieldSelector;

@Slf4j
@Component
@RequiredArgsConstructor
public class StatisticalServiceImpl implements StatisticalService {


private static final GroupVersionKind MOMENT_GVK =
new GroupVersionKind("moment.halo.run", "v1alpha1", "Moment");

private final ReactiveExtensionClient client;
private final SettingConfigGetter settingConfigGetter;
private final ObjectMapper objectMapper = new ObjectMapper();

/**
Expand Down Expand Up @@ -69,6 +81,10 @@ public void clearCache() {
private Mono<PieChartVO> buildPieChartVO() {
PieChartVO pieChartVO = new PieChartVO();

Mono<Boolean> enableMomentHeatmapMono = settingConfigGetter.getBasicsConfig()
.map(config -> Boolean.TRUE.equals(config.getEnableMomentHeatmap()))
.defaultIfEmpty(false);

Mono<List<PieChartVO.Tag>> tagsMono = client.listAll(Tag.class, new ListOptions(),
Sort.by(Sort.Order.desc("metadata.creationTimestamp")))
.map(tag -> {
Expand All @@ -89,11 +105,23 @@ private Mono<PieChartVO> buildPieChartVO() {
})
.collectList();

Mono<List<PieChartVO.Article>> articlesMono = client.listAll(Post.class, new ListOptions(),
// 文章按日聚合
Mono<Map<String, Integer>> postsByDateMono = client.listAll(Post.class, new ListOptions(),
Sort.by(Sort.Order.desc("metadata.creationTimestamp")))
.filter(post -> post.getSpec().getPublishTime() != null)
.collectList()
.map(this::buildArticleList);
.map(posts -> posts.stream()
.collect(Collectors.groupingBy(post -> {
Instant publishTime = post.getSpec().getPublishTime();
if (publishTime != null) {
return toDateStr(publishTime);
}
return toDateStr(post.getMetadata().getCreationTimestamp());
}, Collectors.collectingAndThen(Collectors.counting(), Long::intValue))));

// 瞬间按日聚合(受开关控制)
Mono<Map<String, Integer>> momentsByDateMono = enableMomentHeatmapMono
.flatMap(enabled -> enabled ? getMomentCountsByDate() : Mono.just(Map.of()));

Mono<List<PieChartVO.Comment>> commentsMono = client.listAll(Comment.class, new ListOptions(),
Sort.by(Sort.Order.desc("metadata.creationTimestamp")))
Expand All @@ -108,41 +136,114 @@ private Mono<PieChartVO> buildPieChartVO() {
.take(10)
.collectList();

return Mono.zip(tagsMono, categoriesMono, articlesMono, commentsMono, top10ArticlesMono)
return Mono.zip(tagsMono, categoriesMono, postsByDateMono, momentsByDateMono,
commentsMono, top10ArticlesMono, enableMomentHeatmapMono)
.map(tuple -> {
pieChartVO.setTags(tuple.getT1());
pieChartVO.setCategories(tuple.getT2());
pieChartVO.setArticles(tuple.getT3());
pieChartVO.setComments(tuple.getT4());
pieChartVO.setTop10Articles(tuple.getT5());

Map<String, Integer> postsByDate = tuple.getT3();
Map<String, Integer> momentsByDate = tuple.getT4();
boolean enableMoment = tuple.getT7();

pieChartVO.setArticles(buildArticleList(postsByDate, momentsByDate));
pieChartVO.setComments(tuple.getT5());
pieChartVO.setTop10Articles(tuple.getT6());
pieChartVO.setEnableMomentHeatmap(enableMoment);
return pieChartVO;
});
}

private List<PieChartVO.Article> buildArticleList(List<Post> posts) {
Map<String, Integer> postsByDate = posts.stream()
.collect(Collectors.groupingBy(post -> {
Instant publishTime = post.getSpec().getPublishTime();
if (publishTime != null) {
return publishTime.atZone(Constants.DEFAULT_ZONE_ID)
.toLocalDate().toString();
/**
* 动态查询 moments 并按日期聚合计数
*/
private Mono<Map<String, Integer>> getMomentCountsByDate() {
ListOptions listOptions = new ListOptions();
listOptions.setFieldSelector(FieldSelector.of(Queries.and(
Queries.equal("spec.visible", "PUBLIC"),
Queries.equal("spec.approved", "true")
)));

return Flux.fromIterable(client.indexedQueryEngine().retrieveAll(
MOMENT_GVK,
listOptions,
Sort.by(Sort.Order.desc("spec.releaseTime"))))
.flatMap(name -> client.fetch(MOMENT_GVK, name))
.filter(this::isPublicApprovedMoment)
.collectList()
.map(moments -> {
Map<String, Integer> map = new HashMap<>();
for (Unstructured moment : moments) {
extractMomentDate(moment).ifPresent(dateStr ->
map.merge(dateStr, 1, Integer::sum));
}
return post.getMetadata().getCreationTimestamp()
.atZone(Constants.DEFAULT_ZONE_ID)
.toLocalDate().toString();
}, Collectors.collectingAndThen(Collectors.counting(), Long::intValue)));
return map;
})
.onErrorResume(e -> {
log.warn("查询瞬间数据失败(可能未安装 moments 插件): {}", e.getMessage());
return Mono.just(Map.of());
});
}

/**
* 判断瞬间是否为公开已审核状态
*/
private boolean isPublicApprovedMoment(Unstructured unstructured) {
try {
Map<String, Object> data = unstructured.getData();
Map<String, Object> spec = Unstructured.getNestedMap(data, "spec").orElse(null);
if (spec == null) return false;
Object visible = spec.get("visible");
if (visible != null && !"PUBLIC".equals(visible.toString())) return false;
Object approved = spec.get("approved");
if (approved != null && !Boolean.parseBoolean(approved.toString())) return false;
return true;
} catch (Exception e) {
return false;
}
}

/**
* 提取瞬间的日期字符串(优先 spec.releaseTime,回退 metadata.creationTimestamp)
*/
private Optional<String> extractMomentDate(Unstructured unstructured) {
try {
Map<String, Object> data = unstructured.getData();
Optional<Instant> releaseTime = Unstructured.getNestedInstant(data, "spec", "releaseTime");
if (releaseTime.isPresent()) {
return Optional.of(toDateStr(releaseTime.get()));
}
Instant creation = unstructured.getMetadata().getCreationTimestamp();
if (creation != null) {
return Optional.of(toDateStr(creation));
}
} catch (Exception e) {
log.debug("提取瞬间日期失败: {}", e.getMessage());
}
return Optional.empty();
}

private String toDateStr(Instant instant) {
return instant.atZone(Constants.DEFAULT_ZONE_ID).toLocalDate().toString();
}

private List<PieChartVO.Article> buildArticleList(Map<String, Integer> postsByDate,
Map<String, Integer> momentsByDate) {
LocalDate today = LocalDate.now(Constants.DEFAULT_ZONE_ID);
LocalDate startDate = today.minusYears(1);

return Stream.iterate(startDate, date -> date.plusDays(1))
.limit(java.time.temporal.ChronoUnit.DAYS.between(startDate, today.plusDays(1)))
.map(date -> {
String dateStr = date.toString();
int articleCount = postsByDate.getOrDefault(dateStr, 0);
int momentCount = momentsByDate.getOrDefault(dateStr, 0);
PieChartVO.Article articleVO = new PieChartVO.Article();
articleVO.setName(dateStr);
articleVO.setDate(date.atStartOfDay(Constants.DEFAULT_ZONE_ID).toLocalDateTime());
articleVO.setTotal(postsByDate.getOrDefault(dateStr, 0));
articleVO.setArticleTotal(articleCount);
articleVO.setMomentTotal(momentCount);
articleVO.setTotal(articleCount + momentCount);
return articleVO;
})
.sorted(Comparator.comparing(PieChartVO.Article::getDate).reversed())
Expand Down
4 changes: 4 additions & 0 deletions src/main/java/com/xhhao/dataStatistics/vo/PieChartVO.java
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
@Data
public class PieChartVO {

private Boolean enableMomentHeatmap;

private List<Tag> tags = new ArrayList<>();
@Data
public static class Tag {
Expand All @@ -29,6 +31,8 @@ public static class Article {
private String name; // 名称
private LocalDateTime date; // 日期
private Integer total; // 总数
private Integer articleTotal; // 文章数
private Integer momentTotal; // 瞬间数
}

// 评论
Expand Down
7 changes: 7 additions & 0 deletions src/main/resources/extensions/settings.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,13 @@ spec:
border: "1px solid #d1e7ff"
cursor: "pointer"
children: "复制群号"
- $formkit: checkbox
name: enableMomentHeatmap
id: enableMomentHeatmap
key: enableMomentHeatmap
label: 热力图统计瞬间
value: false
help: 开启后将瞬间并入热力图统计,悬浮提示增加"瞬间"明细
- group: umami
label: umami设置
formSchema:
Expand Down
47 changes: 38 additions & 9 deletions src/main/resources/static/js/siteCharts.js
Original file line number Diff line number Diff line change
Expand Up @@ -313,7 +313,7 @@
return charts;
}

function renderArticleHeatmap(container, articles) {
function renderArticleHeatmap(container, articles, enableMomentHeatmap = false) {
const chartArea = createSection(
container,
'文章发布趋势',
Expand All @@ -332,8 +332,16 @@
}
date.setHours(0, 0, 0, 0);
const key = formatDateYMD(date);
const total = Number(article.total ?? article.count ?? 0);
dataMap.set(key, (dataMap.get(key) || 0) + total);
const articleTotal = Number(article.articleTotal ?? article.total ?? article.count ?? 0);
const momentTotal = Number(article.momentTotal ?? 0);
const rawTotal = Number(article.total ?? 0);
const total = Number.isFinite(rawTotal) ? rawTotal : (articleTotal + momentTotal);
const current = dataMap.get(key) || { total: 0, articleTotal: 0, momentTotal: 0 };
dataMap.set(key, {
total: current.total + total,
articleTotal: current.articleTotal + articleTotal,
momentTotal: current.momentTotal + momentTotal
});
});

if (!dataMap.size) {
Expand All @@ -356,7 +364,7 @@
new Date(firstMonday.getTime() + index * 7 * DAY_IN_MS)
);

const maxValue = Math.max(...dataMap.values(), 0);
const maxValue = Math.max(...[...dataMap.values()].map(v => v.total), 0);

const card = document.createElement('div');
card.className = 'xhhaocom-chartboard-card xhhaocom-chartboard-card--heatmap';
Expand Down Expand Up @@ -451,7 +459,27 @@
};

const showTooltip = (event, dateKey, value) => {
tooltip.innerHTML = `<strong>${dateKey}</strong><span>${value ? `发布 ${value} 篇文章` : '无文章发布'}</span>`;
const articleTotal = value?.articleTotal ?? 0;
const momentTotal = value?.momentTotal ?? 0;
const lines = [`<strong>${dateKey}</strong>`];

if (enableMomentHeatmap) {
if (articleTotal > 0) {
lines.push(`<span>已发布${articleTotal}篇文章</span>`);
}
if (momentTotal > 0) {
lines.push(`<span>已发布${momentTotal}条瞬间</span>`);
}
if (articleTotal === 0 && momentTotal === 0) {
lines.push('<span>当日无内容发布</span>');
}
} else if (articleTotal > 0) {
lines.push(`<span>已发布${articleTotal}篇文章</span>`);
} else {
lines.push('<span>当日无文章发布</span>');
}

tooltip.innerHTML = lines.join('');
tooltip.style.display = 'flex';

const cardRect = card.getBoundingClientRect();
Expand Down Expand Up @@ -541,10 +569,10 @@
if (!isWithinRange) {
cell.classList.add('is-outside');
} else {
const value = dataMap.get(dayKey) || 0;
const level = computeLevel(value);
const value = dataMap.get(dayKey) || { total: 0, articleTotal: 0, momentTotal: 0 };
const level = computeLevel(value.total);
cell.dataset.level = level.toString();
cell.dataset.value = value.toString();
cell.dataset.value = value.total.toString();
cell.dataset.date = dayKey;

const handleMouseMove = event => showTooltip(event, dayKey, value);
Expand Down Expand Up @@ -898,7 +926,8 @@
}

if (enabledTypes.includes('articles')) {
charts.push(...renderArticleHeatmap(container, data.articles));
charts.push(...renderArticleHeatmap(container, data.articles,
Boolean(data.enableMomentHeatmap)));
}

if (enabledTypes.includes('comments')) {
Expand Down
Loading
Loading