Compare commits

...

3 Commits

Author SHA1 Message Date
Tim
b6c2471bc3 Enhance user timeline grouping and post metadata 2025-09-19 00:22:34 +08:00
tim
4cc2800f09 feat: timeline 基础格式更新 2025-09-18 20:48:46 +08:00
Tim
396434a82e Merge pull request #1005 from nagisa77/codex/add-redis/rabbitmq-configuration-details-to-contributing.md
docs: expand Redis and RabbitMQ setup guidance
2025-09-18 17:50:28 +08:00
9 changed files with 389 additions and 48 deletions

View File

@@ -1,6 +1,7 @@
package com.openisle.dto;
import java.time.LocalDateTime;
import java.util.List;
import lombok.Data;
/** Lightweight post metadata used in user profile lists. */
@@ -11,6 +12,8 @@ public class PostMetaDto {
private String title;
private String snippet;
private LocalDateTime createdAt;
private String category;
private CategoryDto category;
private List<TagDto> tags;
private long views;
private long commentCount;
}

View File

@@ -5,6 +5,7 @@ import com.openisle.model.Comment;
import com.openisle.model.Post;
import com.openisle.model.User;
import com.openisle.service.*;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.Authentication;
@@ -23,8 +24,10 @@ public class UserMapper {
private final PostReadService postReadService;
private final LevelService levelService;
private final MedalService medalService;
private final TagMapper tagMapper;
private final CategoryMapper categoryMapper;
@Value("${app.snippet-length:50}")
@Value("${app.snippet-length}")
private int snippetLength;
public AuthorDto toAuthorDto(User user) {
@@ -88,7 +91,12 @@ public class UserMapper {
dto.setSnippet(content);
}
dto.setCreatedAt(post.getCreatedAt());
dto.setCategory(post.getCategory().getName());
dto.setCategory(categoryMapper.toDto(post.getCategory()));
dto.setTags(post.getTags().stream().map(tagMapper::toDto).collect(Collectors.toList()));
if (post.getLastReplyAt() == null) {
commentService.updatePostCommentStats(post);
}
dto.setCommentCount(post.getCommentCount());
dto.setViews(post.getViews());
return dto;
}

View File

@@ -28,7 +28,7 @@ public class SearchService {
private final CategoryRepository categoryRepository;
private final TagRepository tagRepository;
@org.springframework.beans.factory.annotation.Value("${app.snippet-length:50}")
@org.springframework.beans.factory.annotation.Value("${app.snippet-length}")
private int snippetLength;
public List<User> searchUsers(String keyword) {

View File

@@ -43,7 +43,7 @@ app.avatar.base-url=${AVATAR_BASE_URL:https://api.dicebear.com/6.x}
app.user.posts-limit=${USER_POSTS_LIMIT:10}
app.user.replies-limit=${USER_REPLIES_LIMIT:50}
# Length of extracted snippets for posts and search (-1 to disable truncation)
app.snippet-length=${SNIPPET_LENGTH:50}
app.snippet-length=${SNIPPET_LENGTH:200}
# Captcha configuration
app.captcha.enabled=${CAPTCHA_ENABLED:false}

View File

@@ -94,6 +94,7 @@ body {
font-family: 'WenQuanYi Micro Hei', 'Helvetica Neue', Arial, sans-serif;
background-color: var(--normal-background-color);
color: var(--text-color);
text-underline-offset: 4px;
/* 禁止滚动 */
/* overflow: hidden; */
}

View File

@@ -0,0 +1,168 @@
<template>
<div class="timeline-container">
<div class="timeline-header">
<div class="timeline-title">{{ headerText }}</div>
<div class="timeline-date">{{ headerDate }}</div>
</div>
<div class="comment-content">
<div v-for="entry in entries" :key="entry.comment.id" class="comment-content-item">
<div class="comment-content-item-main">
<comment-one class="comment-content-item-icon" />
<template v-if="!entry.comment.parentComment">
<span class="comment-prefix">
<NuxtLink :to="entry.postLink" class="timeline-link">
{{ entry.comment.post.title }}
</NuxtLink>
下评论了
</span>
<NuxtLink :to="entry.commentLink" class="timeline-comment-link">
{{ stripContent(entry.comment.content) }}
</NuxtLink>
</template>
<template v-else>
<span class="comment-prefix">
<NuxtLink :to="entry.postLink" class="timeline-link">
{{ entry.comment.post.title }}
</NuxtLink>
下对
<NuxtLink :to="entry.parentLink" class="timeline-link">
{{ stripContent(entry.comment.parentComment.content) }}
</NuxtLink>
回复了
</span>
<NuxtLink :to="entry.commentLink" class="timeline-comment-link">
{{ stripContent(entry.comment.content) }}
</NuxtLink>
</template>
</div>
<div class="timeline-date">{{ formatDate(entry.createdAt) }}</div>
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { stripMarkdownLength } from '~/utils/markdown'
import TimeManager from '~/utils/time'
const props = defineProps({
item: {
type: Object,
required: true,
},
})
const entries = computed(() =>
(props.item.entries || []).map((entry) => ({
...entry,
postLink: `/posts/${entry.comment.post.id}`,
commentLink: `/posts/${entry.comment.post.id}#comment-${entry.comment.id}`,
parentLink: entry.comment.parentComment
? `/posts/${entry.comment.post.id}#comment-${entry.comment.parentComment.id}`
: undefined,
})),
)
const commentCount = computed(
() => entries.value.filter((entry) => !entry.comment.parentComment).length,
)
const replyCount = computed(
() => entries.value.filter((entry) => entry.comment.parentComment).length,
)
const headerText = computed(() => {
if (commentCount.value && replyCount.value) {
return `发布了${commentCount.value}条评论和${replyCount.value}条回复`
}
if (commentCount.value) {
return `发布了${commentCount.value}条评论`
}
if (replyCount.value) {
return `发布了${replyCount.value}条回复`
}
return '发布了评论'
})
const headerDate = computed(() => TimeManager.format(props.item.createdAt))
const formatDate = (date) => TimeManager.format(date)
const stripContent = (content) => stripMarkdownLength(content || '', 200)
</script>
<style scoped>
.timeline-container {
display: flex;
flex-direction: column;
gap: 12px;
}
.timeline-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.timeline-title {
font-weight: 600;
color: var(--primary-color);
}
.timeline-date {
color: var(--text-color-secondary);
font-size: 14px;
}
.comment-content {
display: flex;
flex-direction: column;
gap: 12px;
}
.comment-content-item {
display: flex;
flex-direction: column;
gap: 6px;
}
.comment-content-item-main {
display: flex;
flex-direction: column;
gap: 4px;
}
.comment-content-item-icon {
color: var(--primary-color);
font-size: 18px;
}
.comment-prefix {
color: var(--text-color-secondary);
font-size: 14px;
}
.timeline-comment-link {
display: inline-flex;
gap: 6px;
align-items: center;
color: var(--text-color);
font-size: 15px;
line-height: 1.6;
}
.timeline-comment-link:hover {
color: var(--primary-color);
}
.timeline-link {
color: var(--primary-color);
}
.timeline-link:hover {
color: var(--primary-color-hover);
}
</style>

View File

@@ -0,0 +1,79 @@
<template>
<div class="timeline-container">
<div class="timeline-header">
<div class="timeline-title">发布了文章</div>
<div class="timeline-date">{{ formattedDate }}</div>
</div>
<div class="article-container">
<NuxtLink :to="postLink" class="timeline-article-link">
{{ props.item.post.title }}
</NuxtLink>
<div class="timeline-snippet">
{{ postSnippet }}
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { stripMarkdown } from '~/utils/markdown'
import TimeManager from '~/utils/time'
const props = defineProps({
item: {
type: Object,
required: true,
},
})
const formattedDate = computed(() => TimeManager.format(props.item.createdAt))
const postLink = computed(() => `/posts/${props.item.post.id}`)
const postSnippet = computed(() => stripMarkdown(props.item.post?.snippet ?? ''))
</script>
<style scoped>
.timeline-container {
display: flex;
flex-direction: column;
gap: 12px;
}
.timeline-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.timeline-title {
font-weight: 600;
color: var(--primary-color);
}
.timeline-date {
color: var(--text-color-secondary);
font-size: 14px;
}
.article-container {
display: flex;
flex-direction: column;
gap: 8px;
}
.timeline-article-link {
font-size: 18px;
font-weight: 600;
color: var(--text-color);
}
.timeline-article-link:hover {
color: var(--primary-color);
}
.timeline-snippet {
color: var(--text-color-secondary);
font-size: 14px;
line-height: 1.6;
}
</style>

View File

@@ -212,48 +212,9 @@
<div class="timeline-list">
<BaseTimeline :items="filteredTimelineItems">
<template #item="{ item }">
<template v-if="item.type === 'post'">
发布了文章
<NuxtLink :to="`/posts/${item.post.id}`" class="timeline-link">
{{ item.post.title }}
</NuxtLink>
<div class="timeline-date">{{ formatDate(item.createdAt) }}</div>
</template>
<template v-else-if="item.type === 'comment'">
<NuxtLink :to="`/posts/${item.comment.post.id}`" class="timeline-link">
{{ item.comment.post.title }}
</NuxtLink>
下评论了
<NuxtLink
:to="`/posts/${item.comment.post.id}#comment-${item.comment.id}`"
class="timeline-link"
>
{{ stripMarkdownLength(item.comment.content, 200) }}
</NuxtLink>
<div class="timeline-date">{{ formatDate(item.createdAt) }}</div>
</template>
<template v-else-if="item.type === 'reply'">
<NuxtLink :to="`/posts/${item.comment.post.id}`" class="timeline-link">
{{ item.comment.post.title }}
</NuxtLink>
下对
<NuxtLink
:to="`/posts/${item.comment.post.id}#comment-${item.comment.parentComment.id}`"
class="timeline-link"
>
{{ stripMarkdownLength(item.comment.parentComment.content, 200) }}
</NuxtLink>
回复了
<NuxtLink
:to="`/posts/${item.comment.post.id}#comment-${item.comment.id}`"
class="timeline-link"
>
{{ stripMarkdownLength(item.comment.content, 200) }}
</NuxtLink>
<div class="timeline-date">{{ formatDate(item.createdAt) }}</div>
</template>
<ProfileTimelinePostItem v-if="item.type === 'post'" :item="item" />
<ProfileTimelineCommentGroup v-else-if="item.type === 'comment'" :item="item" />
<ProfileTimelineCommentGroup v-else-if="item.type === 'reply'" :item="item" />
<template v-else-if="item.type === 'tag'">
创建了标签
<span class="timeline-link" @click="gotoTag(item.tag)">
@@ -326,6 +287,8 @@ import BasePlaceholder from '~/components/BasePlaceholder.vue'
import BaseTimeline from '~/components/BaseTimeline.vue'
import BaseTabs from '~/components/BaseTabs.vue'
import LevelProgress from '~/components/LevelProgress.vue'
import ProfileTimelineCommentGroup from '~/components/ProfileTimelineCommentGroup.vue'
import ProfileTimelinePostItem from '~/components/ProfileTimelinePostItem.vue'
import UserList from '~/components/UserList.vue'
import { toast } from '~/main'
import { authState, getToken } from '~/utils/auth'
@@ -431,6 +394,22 @@ const fetchSummary = async () => {
}
}
const isSameDay = (a, b) => {
const dateA = new Date(a)
const dateB = new Date(b)
return (
dateA.getFullYear() === dateB.getFullYear() &&
dateA.getMonth() === dateB.getMonth() &&
dateA.getDate() === dateB.getDate()
)
}
const createCommentEntry = (item) => ({
type: item.type,
comment: item.comment,
createdAt: item.createdAt,
})
const fetchTimeline = async () => {
const [postsRes, repliesRes, tagsRes] = await Promise.all([
fetch(`${API_BASE_URL}/api/users/${username}/posts?limit=50`),
@@ -461,7 +440,32 @@ const fetchTimeline = async () => {
})),
]
mapped.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
timelineItems.value = mapped
const grouped = []
for (const item of mapped) {
if (item.type === 'comment') {
const last = grouped[grouped.length - 1]
if (last && last.type === 'comment' && isSameDay(last.createdAt, item.createdAt)) {
last.entries.push(createCommentEntry(item))
if (new Date(item.createdAt) > new Date(last.createdAt)) {
last.createdAt = item.createdAt
}
} else {
grouped.push({
...item,
entries: [createCommentEntry(item)],
})
}
} else if (item.type === 'reply') {
grouped.push({
...item,
entries: [createCommentEntry(item)],
})
} else {
grouped.push(item)
}
}
timelineItems.value = grouped
}
const fetchFollowUsers = async () => {
@@ -903,6 +907,7 @@ watch(selectedTab, async (val) => {
font-size: 12px;
color: gray;
margin-top: 5px;
white-space: nowrap;
}
.timeline-snippet {
@@ -939,6 +944,81 @@ watch(selectedTab, async (val) => {
padding: 40px 0;
}
.ttimeline-container {
margin-top: 2px;
padding-bottom: 30px;
}
.timeline-header {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
}
.timeline-title {
font-size: 18px;
font-weight: bold;
}
.comment-content {
display: flex;
flex-direction: column;
margin-top: 10px;
gap: 5px;
}
.comment-content-item-main {
display: flex;
flex-direction: row;
gap: 5px;
align-items: flex-start;
}
.comment-content-item-icon {
width: 20px;
height: 25px;
display: flex;
align-items: center;
justify-content: center;
}
.comment-content-item {
display: flex;
flex-direction: row;
align-items: flex-start;
justify-content: space-between;
}
.timeline-comment-link {
color: var(--text-color);
word-break: break-word;
text-decoration: underline;
}
.timeline-comment-link:hover {
color: var(--primary-color);
}
.timeline-article-link {
color: var(--text-color);
font-weight: bold;
font-size: 20px;
word-break: break-word;
text-decoration: underline;
}
.timeline-article-link:hover {
color: var(--primary-color);
}
.article-container {
padding: 20px;
border-radius: 10px;
border: 1px solid var(--normal-border-color);
margin-top: 10px;
}
.follow-container {
}

View File

@@ -22,6 +22,7 @@ import {
Moon,
ComputerOne,
Comment,
CommentOne,
Link,
SlyFaceWhitSmile,
Like,
@@ -103,6 +104,7 @@ export default defineNuxtPlugin((nuxtApp) => {
nuxtApp.vueApp.component('Moon', Moon)
nuxtApp.vueApp.component('ComputerOne', ComputerOne)
nuxtApp.vueApp.component('CommentIcon', Comment)
nuxtApp.vueApp.component('CommentOne', CommentOne)
nuxtApp.vueApp.component('LinkIcon', Link)
nuxtApp.vueApp.component('SlyFaceWhitSmile', SlyFaceWhitSmile)
nuxtApp.vueApp.component('Like', Like)