mirror of
https://github.com/nagisa77/OpenIsle.git
synced 2026-02-06 23:21:16 +08:00
Merge pull request #1008 from nagisa77/feature/user_page_timeline
user page timeline
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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; */
|
||||
}
|
||||
|
||||
@@ -95,7 +95,7 @@ export default {
|
||||
}
|
||||
|
||||
.timeline-item:last-child::before {
|
||||
display: none;
|
||||
bottom: 0px;
|
||||
}
|
||||
|
||||
.timeline-content {
|
||||
|
||||
167
frontend_nuxt/components/TimelineCommentGroup.vue
Normal file
167
frontend_nuxt/components/TimelineCommentGroup.vue
Normal file
@@ -0,0 +1,167 @@
|
||||
<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">
|
||||
<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.formatWithDay(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-top: 5px;
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
|
||||
.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);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.comment-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.comment-content-item {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.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: underline;
|
||||
}
|
||||
|
||||
.timeline-comment-link:hover {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.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>
|
||||
147
frontend_nuxt/components/TimelinePostItem.vue
Normal file
147
frontend_nuxt/components/TimelinePostItem.vue
Normal file
@@ -0,0 +1,147 @@
|
||||
<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" />
|
||||
<ArticleTags :tags="item.post?.tags" />
|
||||
<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 { 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;
|
||||
padding-top: 5px;
|
||||
gap: 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;
|
||||
border: 1px solid var(--normal-border-color);
|
||||
border-radius: 10px;
|
||||
padding: 10px;
|
||||
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>
|
||||
111
frontend_nuxt/components/TimelineTagItem.vue
Normal file
111
frontend_nuxt/components/TimelineTagItem.vue
Normal file
@@ -0,0 +1,111 @@
|
||||
<template>
|
||||
<div class="timeline-tag-item">
|
||||
<div class="tags-container">
|
||||
<div class="tags-container-item">
|
||||
<div class="timeline-tag-title">创建了标签</div>
|
||||
<ArticleTags v-if="tag" :tags="[tag]" />
|
||||
<span class="timeline-tag-count" v-if="tag?.count"> x{{ tag.count }}</span>
|
||||
</div>
|
||||
<div v-if="timelineDate" class="timeline-date">{{ timelineDate }}</div>
|
||||
</div>
|
||||
<div v-if="hasDescription" class="timeline-snippet">
|
||||
{{ tag?.description }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import TimeManager from '~/utils/time'
|
||||
|
||||
const props = defineProps({
|
||||
item: { type: Object, required: true },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['tag-click'])
|
||||
|
||||
const tag = computed(() => props.item?.tag ?? null)
|
||||
const hasDescription = computed(() => {
|
||||
const description = tag.value?.description
|
||||
return !!description
|
||||
})
|
||||
|
||||
const timelineDate = computed(() => {
|
||||
const date = props.item?.createdAt ?? tag.value?.createdAt
|
||||
return date ? TimeManager.format(date) : ''
|
||||
})
|
||||
|
||||
const summaryDate = computed(() => {
|
||||
const date = tag.value?.createdAt ?? props.item?.createdAt
|
||||
return date ? TimeManager.format(date) : ''
|
||||
})
|
||||
|
||||
const isClickable = computed(() => props.mode === 'summary' && !!tag.value)
|
||||
|
||||
const handleTagClick = () => {
|
||||
if (!isClickable.value) return
|
||||
emit('tag-click', tag.value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.timeline-tag-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.timeline-tag-count {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.timeline-date {
|
||||
font-size: 12px;
|
||||
color: gray;
|
||||
margin-top: 5px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.timeline-snippet {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.timeline-link {
|
||||
font-weight: bold;
|
||||
color: var(--primary-color);
|
||||
text-decoration: none;
|
||||
word-break: break-word;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.timeline-link.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.timeline-link.clickable:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
@@ -112,19 +112,18 @@
|
||||
{{ item.comment.post.title }}
|
||||
</NuxtLink>
|
||||
<template v-if="item.comment.parentComment">
|
||||
下对
|
||||
<NuxtLink
|
||||
:to="`/posts/${item.comment.post.id}#comment-${item.comment.parentComment.id}`"
|
||||
class="timeline-link"
|
||||
class="timeline-comment-link"
|
||||
>
|
||||
{{ stripMarkdownLength(item.comment.parentComment.content, 200) }}
|
||||
</NuxtLink>
|
||||
回复了
|
||||
<next class="reply-icon" /> 回复了
|
||||
</template>
|
||||
<template v-else> 下评论了 </template>
|
||||
<NuxtLink
|
||||
:to="`/posts/${item.comment.post.id}#comment-${item.comment.id}`"
|
||||
class="timeline-link"
|
||||
class="timeline-comment-link"
|
||||
>
|
||||
{{ stripMarkdownLength(item.comment.content, 200) }}
|
||||
</NuxtLink>
|
||||
@@ -143,15 +142,7 @@
|
||||
<div class="summary-content" v-if="hotPosts.length > 0">
|
||||
<BaseTimeline :items="hotPosts">
|
||||
<template #item="{ item }">
|
||||
<NuxtLink :to="`/posts/${item.post.id}`" class="timeline-link">
|
||||
{{ item.post.title }}
|
||||
</NuxtLink>
|
||||
<div class="timeline-snippet">
|
||||
{{ stripMarkdown(item.post.snippet) }}
|
||||
</div>
|
||||
<div class="timeline-date">
|
||||
{{ formatDate(item.post.createdAt) }}
|
||||
</div>
|
||||
<TimelinePostItem :item="item" />
|
||||
</template>
|
||||
</BaseTimeline>
|
||||
</div>
|
||||
@@ -164,15 +155,7 @@
|
||||
<div class="summary-content" v-if="hotTags.length > 0">
|
||||
<BaseTimeline :items="hotTags">
|
||||
<template #item="{ item }">
|
||||
<span class="timeline-link" @click="gotoTag(item.tag)">
|
||||
{{ item.tag.name }}<span v-if="item.tag.count"> x{{ item.tag.count }}</span>
|
||||
</span>
|
||||
<div class="timeline-snippet" v-if="item.tag.description">
|
||||
{{ item.tag.description }}
|
||||
</div>
|
||||
<div class="timeline-date">
|
||||
{{ formatDate(item.tag.createdAt) }}
|
||||
</div>
|
||||
<TimelineTagItem :item="item" />
|
||||
</template>
|
||||
</BaseTimeline>
|
||||
</div>
|
||||
@@ -213,56 +196,16 @@
|
||||
<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>
|
||||
<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>
|
||||
<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'">
|
||||
创建了标签
|
||||
<span class="timeline-link" @click="gotoTag(item.tag)">
|
||||
{{ item.tag.name }}<span v-if="item.tag.count"> x{{ item.tag.count }}</span>
|
||||
</span>
|
||||
<div class="timeline-snippet" v-if="item.tag.description">
|
||||
{{ item.tag.description }}
|
||||
</div>
|
||||
<div class="timeline-date">{{ formatDate(item.createdAt) }}</div>
|
||||
<TimelineTagItem :item="item" />
|
||||
</template>
|
||||
</template>
|
||||
</BaseTimeline>
|
||||
@@ -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 {
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}`
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user