diff --git a/backend/src/main/java/com/openisle/dto/PostMetaDto.java b/backend/src/main/java/com/openisle/dto/PostMetaDto.java index 1667b047f..81df37a9f 100644 --- a/backend/src/main/java/com/openisle/dto/PostMetaDto.java +++ b/backend/src/main/java/com/openisle/dto/PostMetaDto.java @@ -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 tags; private long views; + private long commentCount; } diff --git a/backend/src/main/java/com/openisle/mapper/UserMapper.java b/backend/src/main/java/com/openisle/mapper/UserMapper.java index ea5e85b67..99203bf2d 100644 --- a/backend/src/main/java/com/openisle/mapper/UserMapper.java +++ b/backend/src/main/java/com/openisle/mapper/UserMapper.java @@ -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 CategoryMapper categoryMapper; + private final TagMapper tagMapper; - @Value("${app.snippet-length:50}") + @Value("${app.snippet-length}") private int snippetLength; public AuthorDto toAuthorDto(User user) { @@ -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; } diff --git a/backend/src/main/java/com/openisle/service/SearchService.java b/backend/src/main/java/com/openisle/service/SearchService.java index 5b9485f42..dee83fed9 100644 --- a/backend/src/main/java/com/openisle/service/SearchService.java +++ b/backend/src/main/java/com/openisle/service/SearchService.java @@ -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 searchUsers(String keyword) { diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index 1d5e4844e..00ccc0302 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -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} diff --git a/backend/src/test/java/com/openisle/controller/UserControllerTest.java b/backend/src/test/java/com/openisle/controller/UserControllerTest.java index 6c47232d5..ec0951368 100644 --- a/backend/src/test/java/com/openisle/controller/UserControllerTest.java +++ b/backend/src/test/java/com/openisle/controller/UserControllerTest.java @@ -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(); diff --git a/frontend_nuxt/assets/global.css b/frontend_nuxt/assets/global.css index 2cf617fa1..be5108a00 100644 --- a/frontend_nuxt/assets/global.css +++ b/frontend_nuxt/assets/global.css @@ -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; */ } diff --git a/frontend_nuxt/components/BaseTimeline.vue b/frontend_nuxt/components/BaseTimeline.vue index f5d48e537..5888499db 100644 --- a/frontend_nuxt/components/BaseTimeline.vue +++ b/frontend_nuxt/components/BaseTimeline.vue @@ -95,7 +95,7 @@ export default { } .timeline-item:last-child::before { - display: none; + bottom: 0px; } .timeline-content { diff --git a/frontend_nuxt/components/TimelineCommentGroup.vue b/frontend_nuxt/components/TimelineCommentGroup.vue new file mode 100644 index 000000000..c7b1cd2f7 --- /dev/null +++ b/frontend_nuxt/components/TimelineCommentGroup.vue @@ -0,0 +1,167 @@ + + + + + diff --git a/frontend_nuxt/components/TimelinePostItem.vue b/frontend_nuxt/components/TimelinePostItem.vue new file mode 100644 index 000000000..77dc6c69c --- /dev/null +++ b/frontend_nuxt/components/TimelinePostItem.vue @@ -0,0 +1,147 @@ + + + + + diff --git a/frontend_nuxt/components/TimelineTagItem.vue b/frontend_nuxt/components/TimelineTagItem.vue new file mode 100644 index 000000000..61a79dcb8 --- /dev/null +++ b/frontend_nuxt/components/TimelineTagItem.vue @@ -0,0 +1,111 @@ + + + + + diff --git a/frontend_nuxt/pages/users/[id].vue b/frontend_nuxt/pages/users/[id].vue index b78aa56f6..28fd4e6ba 100644 --- a/frontend_nuxt/pages/users/[id].vue +++ b/frontend_nuxt/pages/users/[id].vue @@ -112,19 +112,18 @@ {{ item.comment.post.title }} {{ stripMarkdownLength(item.comment.content, 200) }} @@ -143,15 +142,7 @@
@@ -164,15 +155,7 @@
@@ -213,56 +196,16 @@ @@ -326,6 +269,9 @@ 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 TimelineTagItem from '~/components/TimelineTagItem.vue' import UserList from '~/components/UserList.vue' import { toast } from '~/main' import { authState, getToken } from '~/utils/auth' @@ -415,7 +361,12 @@ const fetchSummary = async () => { const postsRes = await fetch(`${API_BASE_URL}/api/users/${username}/hot-posts`) if (postsRes.ok) { const data = await postsRes.json() - hotPosts.value = data.map((p) => ({ icon: 'file-text', post: p })) + hotPosts.value = data.map((p) => ({ + icon: 'file-text', + type: 'post', + post: p, + createdAt: p.createdAt, + })) } const repliesRes = await fetch(`${API_BASE_URL}/api/users/${username}/hot-replies`) @@ -427,10 +378,66 @@ const fetchSummary = async () => { const tagsRes = await fetch(`${API_BASE_URL}/api/users/${username}/hot-tags`) if (tagsRes.ok) { const data = await tagsRes.json() - hotTags.value = data.map((t) => ({ icon: 'tag-one', tag: t })) + hotTags.value = data.map((t) => ({ + icon: 'tag-one', + type: 'tag', + tag: t, + createdAt: t.createdAt, + })) } } +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`), @@ -461,7 +468,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 () => { @@ -662,6 +669,11 @@ watch(selectedTab, async (val) => { color: #666; } +.reply-icon { + color: var(--primary-color); + margin-left: 5px; +} + .profile-page-header-user-info-buttons { display: flex; flex-direction: row; @@ -903,6 +915,7 @@ watch(selectedTab, async (val) => { font-size: 12px; color: gray; margin-top: 5px; + white-space: nowrap; } .timeline-snippet { @@ -913,8 +926,8 @@ watch(selectedTab, async (val) => { .timeline-link { font-weight: bold; - color: var(--primary-color); text-decoration: none; + color: var(--text-color); word-break: break-word; } @@ -939,6 +952,98 @@ 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; +} + +.tags-container { + display: flex; + flex-direction: row; + gap: 10px; + padding-top: 5px; + justify-content: space-between; + align-items: center; +} + +.tags-container-item { + display: flex; + flex-direction: row; + gap: 5px; + align-items: center; +} + +.timeline-tag-title { + font-size: 16px; + font-weight: 600; +} + +.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; + margin-left: 5px; +} + +.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 { } diff --git a/frontend_nuxt/plugins/iconpark.client.ts b/frontend_nuxt/plugins/iconpark.client.ts index b9dce953f..985cfa6ac 100644 --- a/frontend_nuxt/plugins/iconpark.client.ts +++ b/frontend_nuxt/plugins/iconpark.client.ts @@ -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) diff --git a/frontend_nuxt/utils/time.js b/frontend_nuxt/utils/time.js index 3257e7fac..e9b5127c2 100644 --- a/frontend_nuxt/utils/time.js +++ b/frontend_nuxt/utils/time.js @@ -40,4 +40,33 @@ export default class TimeManager { return `${date.getFullYear()}.${month}.${day} ${timePart}` } + + // 仅显示日期(不含时间) + static formatWithDay(input) { + const date = new Date(input) + if (Number.isNaN(date.getTime())) return '' + + const now = new Date() + + const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()) + const startOfDate = new Date(date.getFullYear(), date.getMonth(), date.getDate()) + const diffDays = Math.floor((startOfToday - startOfDate) / 86400000) + + if (diffDays === 0) return '今天' + if (diffDays === 1) return '昨天' + if (diffDays === 2) return '前天' + + const month = date.getMonth() + 1 + const day = date.getDate() + + if (date.getFullYear() === now.getFullYear()) { + return `${month}.${day}` + } + + if (date.getFullYear() === now.getFullYear() - 1) { + return `去年 ${month}.${day}` + } + + return `${date.getFullYear()}.${month}.${day}` + } }