Compare commits

...

1 Commits

Author SHA1 Message Date
Tim
e7593c8ebf Enhance user timeline post metadata and grouping 2025-09-19 00:31:52 +08:00
6 changed files with 405 additions and 91 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,6 +24,8 @@ public class UserMapper {
private final PostReadService postReadService;
private final LevelService levelService;
private final MedalService medalService;
private final CategoryMapper categoryMapper;
private final TagMapper tagMapper;
@Value("${app.snippet-length}")
private int snippetLength;
@@ -88,8 +91,10 @@ 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()));
dto.setViews(post.getViews());
dto.setCommentCount(post.getCommentCount());
return dto;
}

View File

@@ -8,6 +8,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
import com.openisle.dto.CommentInfoDto;
import com.openisle.dto.PostMetaDto;
import com.openisle.dto.UserDto;
import com.openisle.mapper.CategoryMapper;
import com.openisle.mapper.TagMapper;
import com.openisle.mapper.UserMapper;
import com.openisle.model.User;
@@ -64,6 +65,9 @@ class UserControllerTest {
@MockBean
private TagMapper tagMapper;
@MockBean
private CategoryMapper categoryMapper;
@Test
void getCurrentUser() throws Exception {
User u = new User();

View File

@@ -0,0 +1,185 @@
<template>
<div class="timeline-container">
<div class="timeline-header">
<div class="timeline-title">{{ headerText }}</div>
<div class="timeline-date">{{ formattedDate }}</div>
</div>
<div class="comment-content" v-if="entries.length > 0">
<div class="comment-content-item" v-for="entry in entries" :key="entry.comment.id">
<div class="comment-content-item-main">
<comment-one class="comment-content-item-icon" />
<div class="comment-content-item-text">
<span class="comment-content-item-prefix">
<NuxtLink :to="`/posts/${entry.comment.post.id}`" class="timeline-link">
{{ entry.comment.post.title }}
</NuxtLink>
<template v-if="entry.comment.parentComment">
下对
<NuxtLink
:to="`/posts/${entry.comment.post.id}#comment-${entry.comment.parentComment.id}`"
class="timeline-link"
>
{{ parentSnippet(entry) }}
</NuxtLink>
回复了
</template>
<template v-else> 下评论了 </template>
</span>
<NuxtLink
:to="`/posts/${entry.comment.post.id}#comment-${entry.comment.id}`"
class="timeline-comment-link"
>
{{ stripContent(entry.comment.content) }}
</NuxtLink>
</div>
</div>
<div class="timeline-date">{{ formatEntryDate(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(() => {
if (Array.isArray(props.item.entries)) {
return props.item.entries
}
if (props.item.comment) {
return [
{
type: props.item.type,
comment: props.item.comment,
createdAt: props.item.createdAt,
},
]
}
return []
})
const formattedDate = computed(() => TimeManager.format(props.item.createdAt))
const hasReplies = computed(() => entries.value.some((entry) => !!entry.comment.parentComment))
const hasComments = computed(() => entries.value.some((entry) => !entry.comment.parentComment))
const headerText = computed(() => {
const count = entries.value.length
if (count === 0) return ''
if (hasComments.value && hasReplies.value) {
return `发布了${count}条评论/回复`
}
if (hasReplies.value) {
return `发布了${count}条回复`
}
return `发布了${count}条评论`
})
const formatEntryDate = (date) => TimeManager.format(date)
const stripContent = (content) => stripMarkdownLength(content ?? '', 200)
const parentSnippet = (entry) =>
stripMarkdownLength(entry.comment.parentComment?.content ?? '', 200)
</script>
<style scoped>
.timeline-container {
display: flex;
flex-direction: column;
gap: 12px;
padding: 10px 12px;
border-radius: 10px;
background: var(--timeline-card-background, transparent);
}
.timeline-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.timeline-title {
font-size: 16px;
font-weight: 600;
}
.timeline-date {
font-size: 12px;
color: var(--timeline-date-color, #888);
}
.comment-content {
display: flex;
flex-direction: column;
gap: 16px;
}
.comment-content-item {
display: flex;
flex-direction: column;
gap: 6px;
padding-bottom: 6px;
border-bottom: 1px solid var(--comment-item-border, rgba(0, 0, 0, 0.05));
}
.comment-content-item:last-child {
border-bottom: none;
padding-bottom: 0;
}
.comment-content-item-main {
display: flex;
gap: 8px;
align-items: flex-start;
}
.comment-content-item-icon {
flex-shrink: 0;
width: 18px;
height: 18px;
margin-top: 2px;
}
.comment-content-item-text {
display: flex;
flex-direction: column;
gap: 4px;
}
.comment-content-item-prefix {
font-size: 14px;
color: var(--text-color);
}
.timeline-comment-link {
font-size: 14px;
color: var(--link-color);
text-decoration: none;
}
.timeline-comment-link:hover {
text-decoration: underline;
}
.timeline-link {
color: var(--link-color);
text-decoration: none;
}
.timeline-link:hover {
text-decoration: underline;
}
@media (max-width: 768px) {
.comment-content-item-prefix,
.timeline-comment-link {
font-size: 13px;
}
}
</style>

View File

@@ -0,0 +1,149 @@
<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">
{{ item.post?.title }}
</NuxtLink>
<div class="timeline-snippet">
{{ strippedSnippet }}
</div>
<div class="article-meta" v-if="hasMeta">
<ArticleCategory v-if="item.post?.category" :category="item.post.category" />
<div class="article-tags" v-if="(item.post?.tags?.length ?? 0) > 0">
<span class="article-tag" v-for="tag in item.post?.tags" :key="tag.id || tag.name">
#{{ tag.name }}
</span>
</div>
<div class="article-comment-count" v-if="item.post?.commentCount !== undefined">
<comment-one class="article-comment-count-icon" />
<span>{{ item.post?.commentCount }}</span>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import ArticleCategory from '~/components/ArticleCategory.vue'
import { stripMarkdown } from '~/utils/markdown'
import TimeManager from '~/utils/time'
const props = defineProps({
item: { type: Object, required: true },
})
const postLink = computed(() => {
const id = props.item.post?.id
return id ? `/posts/${id}` : '#'
})
const formattedDate = computed(() => TimeManager.format(props.item.createdAt))
const strippedSnippet = computed(() => stripMarkdown(props.item.post?.snippet ?? ''))
const hasMeta = computed(() => {
const tags = props.item.post?.tags ?? []
const hasTags = Array.isArray(tags) && tags.length > 0
const hasCategory = !!props.item.post?.category
const hasCommentCount =
props.item.post?.commentCount !== undefined && props.item.post?.commentCount !== null
return hasTags || hasCategory || hasCommentCount
})
</script>
<style scoped>
.timeline-container {
display: flex;
flex-direction: column;
gap: 12px;
padding: 10px 12px;
border-radius: 10px;
background: var(--timeline-card-background, transparent);
}
.timeline-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.timeline-title {
font-size: 16px;
font-weight: 600;
}
.timeline-date {
font-size: 12px;
color: var(--timeline-date-color, #888);
}
.article-container {
display: flex;
flex-direction: column;
gap: 6px;
}
.timeline-article-link {
font-size: 18px;
font-weight: 600;
color: var(--link-color);
text-decoration: none;
}
.timeline-article-link:hover {
text-decoration: underline;
}
.timeline-snippet {
color: var(--timeline-snippet-color, #666);
font-size: 14px;
line-height: 1.6;
}
.article-meta {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.article-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.article-tag {
background-color: var(--article-info-background-color);
border-radius: 6px;
padding: 2px 6px;
font-size: 12px;
color: var(--text-color);
}
.article-comment-count {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: var(--text-color);
}
.article-comment-count-icon {
width: 16px;
height: 16px;
}
@media (max-width: 768px) {
.timeline-article-link {
font-size: 16px;
}
.timeline-snippet {
font-size: 13px;
}
}
</style>

View File

@@ -220,98 +220,13 @@
<div class="timeline-date">{{ formatDate(item.createdAt) }}</div>
</template> -->
<template v-if="item.type === 'post'">
<div class="ttimeline-container">
<div class="timeline-header">
<div class="timeline-title">发布了文章</div>
<div class="timeline-date">{{ formatDate(item.createdAt) }}</div>
</div>
<div class="article-container">
<NuxtLink :to="`/posts/${item.post.id}`" class="timeline-article-link">
{{ item.post.title }}
</NuxtLink>
<div class="timeline-snippet">
{{ stripMarkdown(item.post.snippet) }}
</div>
</div>
</div>
<TimelinePostItem :item="item" />
</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 === 'comment'">
<div class="ttimeline-container">
<div class="timeline-header">
<div class="timeline-title">发布了4条评论</div>
<div class="timeline-date">{{ formatDate(item.createdAt) }}</div>
</div>
<div class="comment-content">
<div class="comment-content-item">
<div class="comment-content-item-main">
<comment-one class="comment-content-item-icon" />
<NuxtLink
:to="`/posts/${item.comment.post.id}#comment-${item.comment.id}`"
class="timeline-comment-link"
>
{{ stripMarkdownLength(item.comment.content, 200) }}
</NuxtLink>
</div>
<div class="timeline-date">{{ formatDate(item.createdAt) }}</div>
</div>
<div class="comment-content-item">
<div class="comment-content-item-main">
<comment-one class="comment-content-item-icon" />
<NuxtLink
:to="`/posts/${item.comment.post.id}#comment-${item.comment.id}`"
class="timeline-comment-link"
>
{{ stripMarkdownLength(item.comment.content, 200) }}
</NuxtLink>
</div>
<div class="timeline-date">{{ formatDate(item.createdAt) }}</div>
</div>
<div class="comment-content-item">
<div class="comment-content-item-main">
<comment-one class="comment-content-item-icon" />
<NuxtLink
:to="`/posts/${item.comment.post.id}#comment-${item.comment.id}`"
class="timeline-comment-link"
>
{{ stripMarkdownLength(item.comment.content, 200) }}
</NuxtLink>
</div>
<div class="timeline-date">{{ formatDate(item.createdAt) }}</div>
</div>
</div>
</div>
<TimelineCommentGroup :item="item" />
</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>
<TimelineCommentGroup :item="item" />
</template>
<template v-else-if="item.type === 'tag'">
创建了标签
@@ -385,6 +300,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 TimelineCommentGroup from '~/components/TimelineCommentGroup.vue'
import TimelinePostItem from '~/components/TimelinePostItem.vue'
import UserList from '~/components/UserList.vue'
import { toast } from '~/main'
import { authState, getToken } from '~/utils/auth'
@@ -490,6 +407,57 @@ const fetchSummary = async () => {
}
}
const isDiscussionItem = (item) => item && (item.type === 'comment' || item.type === 'reply')
const toDateKey = (value) => {
const date = new Date(value)
if (Number.isNaN(date.getTime())) return ''
const month = `${date.getMonth() + 1}`.padStart(2, '0')
const day = `${date.getDate()}`.padStart(2, '0')
return `${date.getFullYear()}-${month}-${day}`
}
const combineDiscussionItems = (items) => {
const result = []
items.forEach((item) => {
if (!isDiscussionItem(item)) {
result.push(item)
return
}
const dateKey = toDateKey(item.createdAt)
const last = result[result.length - 1]
if (last && isDiscussionItem(last) && last.dateKey === dateKey) {
last.entries.push({
type: item.type,
comment: item.comment,
createdAt: item.createdAt,
})
if (item.type === 'comment' && last.type === 'reply') {
last.type = 'comment'
}
if (new Date(item.createdAt) > new Date(last.createdAt)) {
last.createdAt = item.createdAt
}
} else {
result.push({
type: item.type,
icon: item.icon,
createdAt: item.createdAt,
dateKey,
entries: [
{
type: item.type,
comment: item.comment,
createdAt: item.createdAt,
},
],
})
}
})
return result
}
const fetchTimeline = async () => {
const [postsRes, repliesRes, tagsRes] = await Promise.all([
fetch(`${API_BASE_URL}/api/users/${username}/posts?limit=50`),
@@ -520,7 +488,7 @@ const fetchTimeline = async () => {
})),
]
mapped.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
timelineItems.value = mapped
timelineItems.value = combineDiscussionItems(mapped)
}
const fetchFollowUsers = async () => {