mirror of
https://github.com/nagisa77/OpenIsle.git
synced 2026-02-06 15:10:59 +08:00
Merge pull request #1134 from nagisa77/feature/view_history
fix: add view history logic
This commit is contained in:
@@ -34,6 +34,7 @@ public class UserController {
|
||||
private final TagService tagService;
|
||||
private final SubscriptionService subscriptionService;
|
||||
private final LevelService levelService;
|
||||
private final PostReadService postReadService;
|
||||
private final JwtService jwtService;
|
||||
private final UserMapper userMapper;
|
||||
private final TagMapper tagMapper;
|
||||
@@ -53,6 +54,9 @@ public class UserController {
|
||||
@Value("${app.user.tags-limit:50}")
|
||||
private int defaultTagsLimit;
|
||||
|
||||
@Value("${app.user.read-posts-limit:50}")
|
||||
private int defaultReadPostsLimit;
|
||||
|
||||
@GetMapping("/me")
|
||||
@SecurityRequirement(name = "JWT")
|
||||
@Operation(summary = "Current user", description = "Get current authenticated user information")
|
||||
@@ -211,6 +215,33 @@ public class UserController {
|
||||
.collect(java.util.stream.Collectors.toList());
|
||||
}
|
||||
|
||||
@GetMapping("/{identifier}/read-posts")
|
||||
@SecurityRequirement(name = "JWT")
|
||||
@Operation(summary = "User read posts", description = "Get post read history (self only)")
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "Post read history",
|
||||
content = @Content(array = @ArraySchema(schema = @Schema(implementation = PostReadDto.class)))
|
||||
)
|
||||
public ResponseEntity<java.util.List<PostReadDto>> userReadPosts(
|
||||
@PathVariable("identifier") String identifier,
|
||||
@RequestParam(value = "limit", required = false) Integer limit,
|
||||
Authentication auth
|
||||
) {
|
||||
User user = userService.findByIdentifier(identifier).orElseThrow();
|
||||
if (auth == null || !auth.getName().equals(user.getUsername())) {
|
||||
return ResponseEntity.status(403).body(java.util.List.of());
|
||||
}
|
||||
int l = limit != null ? limit : defaultReadPostsLimit;
|
||||
return ResponseEntity.ok(
|
||||
postReadService
|
||||
.getRecentReadsByUser(user.getUsername(), l)
|
||||
.stream()
|
||||
.map(userMapper::toPostReadDto)
|
||||
.collect(java.util.stream.Collectors.toList())
|
||||
);
|
||||
}
|
||||
|
||||
@GetMapping("/{identifier}/hot-posts")
|
||||
@Operation(summary = "User hot posts", description = "Get most reacted posts by user")
|
||||
@ApiResponse(
|
||||
|
||||
12
backend/src/main/java/com/openisle/dto/PostReadDto.java
Normal file
12
backend/src/main/java/com/openisle/dto/PostReadDto.java
Normal file
@@ -0,0 +1,12 @@
|
||||
package com.openisle.dto;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import lombok.Data;
|
||||
|
||||
/** DTO for a user's post read record. */
|
||||
@Data
|
||||
public class PostReadDto {
|
||||
|
||||
private PostMetaDto post;
|
||||
private LocalDateTime lastReadAt;
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package com.openisle.mapper;
|
||||
import com.openisle.dto.*;
|
||||
import com.openisle.model.Comment;
|
||||
import com.openisle.model.Post;
|
||||
import com.openisle.model.PostRead;
|
||||
import com.openisle.model.User;
|
||||
import com.openisle.service.*;
|
||||
import java.util.stream.Collectors;
|
||||
@@ -115,4 +116,11 @@ public class UserMapper {
|
||||
}
|
||||
return dto;
|
||||
}
|
||||
|
||||
public PostReadDto toPostReadDto(PostRead read) {
|
||||
PostReadDto dto = new PostReadDto();
|
||||
dto.setPost(toMetaDto(read.getPost()));
|
||||
dto.setLastReadAt(read.getLastReadAt());
|
||||
return dto;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,11 +3,14 @@ package com.openisle.repository;
|
||||
import com.openisle.model.Post;
|
||||
import com.openisle.model.PostRead;
|
||||
import com.openisle.model.User;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
public interface PostReadRepository extends JpaRepository<PostRead, Long> {
|
||||
Optional<PostRead> findByUserAndPost(User user, Post post);
|
||||
List<PostRead> findByUserOrderByLastReadAtDesc(User user, Pageable pageable);
|
||||
long countByUser(User user);
|
||||
void deleteByPost(Post post);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,10 @@ import com.openisle.repository.PostReadRepository;
|
||||
import com.openisle.repository.PostRepository;
|
||||
import com.openisle.repository.UserRepository;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
@@ -43,6 +46,14 @@ public class PostReadService {
|
||||
);
|
||||
}
|
||||
|
||||
public List<PostRead> getRecentReadsByUser(String username, int limit) {
|
||||
User user = userRepository
|
||||
.findByUsername(username)
|
||||
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
|
||||
Pageable pageable = PageRequest.of(0, limit);
|
||||
return postReadRepository.findByUserOrderByLastReadAtDesc(user, pageable);
|
||||
}
|
||||
|
||||
public long countReads(String username) {
|
||||
User user = userRepository
|
||||
.findByUsername(username)
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
CREATE TABLE IF NOT EXISTS post_reads (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT,
|
||||
user_id BIGINT NOT NULL,
|
||||
post_id BIGINT NOT NULL,
|
||||
last_read_at DATETIME(6) DEFAULT NULL,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY UK_post_reads_user_post (user_id, post_id),
|
||||
KEY IDX_post_reads_user (user_id),
|
||||
KEY IDX_post_reads_post (post_id),
|
||||
CONSTRAINT FK_post_reads_user FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
|
||||
CONSTRAINT FK_post_reads_post FOREIGN KEY (post_id) REFERENCES posts (id) ON DELETE CASCADE
|
||||
);
|
||||
149
frontend_nuxt/components/TimelineReadItem.vue
Normal file
149
frontend_nuxt/components/TimelineReadItem.vue
Normal 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" />
|
||||
<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.lastReadAt ?? 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>
|
||||
@@ -191,14 +191,25 @@
|
||||
>
|
||||
评论和回复
|
||||
</div>
|
||||
<div
|
||||
v-if="isMine"
|
||||
:class="['timeline-tab-item', { selected: timelineFilter === 'reads' }]"
|
||||
@click="timelineFilter = 'reads'"
|
||||
>
|
||||
浏览记录
|
||||
</div>
|
||||
</div>
|
||||
<BasePlaceholder
|
||||
v-if="filteredTimelineItems.length === 0"
|
||||
text="暂无时间线"
|
||||
v-if="
|
||||
timelineFilter === 'reads'
|
||||
? readPosts.length === 0
|
||||
: filteredTimelineItems.length === 0
|
||||
"
|
||||
:text="timelineFilter === 'reads' ? '暂无浏览记录' : '暂无时间线'"
|
||||
icon="inbox"
|
||||
/>
|
||||
<div class="timeline-list">
|
||||
<BaseTimeline :items="filteredTimelineItems">
|
||||
<BaseTimeline v-if="timelineFilter !== 'reads'" :items="filteredTimelineItems">
|
||||
<template #item="{ item }">
|
||||
<template v-if="item.type === 'post'">
|
||||
<TimelinePostItem :item="item" />
|
||||
@@ -214,6 +225,11 @@
|
||||
</template>
|
||||
</template>
|
||||
</BaseTimeline>
|
||||
<BaseTimeline v-else :items="readPosts">
|
||||
<template #item="{ item }">
|
||||
<TimelineReadItem :item="item" />
|
||||
</template>
|
||||
</BaseTimeline>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -276,6 +292,7 @@ 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 TimelineReadItem from '~/components/TimelineReadItem.vue'
|
||||
import TimelineTagItem from '~/components/TimelineTagItem.vue'
|
||||
import BaseUserAvatar from '~/components/BaseUserAvatar.vue'
|
||||
import UserList from '~/components/UserList.vue'
|
||||
@@ -299,12 +316,15 @@ const hotReplies = ref([])
|
||||
const hotTags = ref([])
|
||||
const favoritePosts = ref([])
|
||||
const timelineItems = ref([])
|
||||
const readPosts = ref([])
|
||||
const timelineFilter = ref('all')
|
||||
const filteredTimelineItems = computed(() => {
|
||||
if (timelineFilter.value === 'articles') {
|
||||
return timelineItems.value.filter((item) => item.type === 'post')
|
||||
} else if (timelineFilter.value === 'comments') {
|
||||
return timelineItems.value.filter((item) => item.type === 'comment' || item.type === 'reply')
|
||||
} else if (timelineFilter.value === 'reads') {
|
||||
return []
|
||||
}
|
||||
return timelineItems.value
|
||||
})
|
||||
@@ -477,6 +497,27 @@ const fetchTimeline = async () => {
|
||||
timelineItems.value = combineDiscussionItems(mapped)
|
||||
}
|
||||
|
||||
const fetchReadHistory = async () => {
|
||||
if (!isMine.value) {
|
||||
readPosts.value = []
|
||||
return
|
||||
}
|
||||
const token = getToken()
|
||||
if (!token) {
|
||||
readPosts.value = []
|
||||
return
|
||||
}
|
||||
const res = await fetch(`${API_BASE_URL}/api/users/${username}/read-posts?limit=50`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
readPosts.value = data.map((r) => ({ ...r, icon: 'file-text' }))
|
||||
} else {
|
||||
readPosts.value = []
|
||||
}
|
||||
}
|
||||
|
||||
const fetchFollowUsers = async () => {
|
||||
const [followerRes, followingRes] = await Promise.all([
|
||||
fetch(`${API_BASE_URL}/api/users/${username}/followers`),
|
||||
@@ -508,6 +549,12 @@ const loadTimeline = async () => {
|
||||
tabLoading.value = false
|
||||
}
|
||||
|
||||
const loadReadHistory = async () => {
|
||||
tabLoading.value = true
|
||||
await fetchReadHistory()
|
||||
tabLoading.value = false
|
||||
}
|
||||
|
||||
const loadFollow = async () => {
|
||||
tabLoading.value = true
|
||||
await fetchFollowUsers()
|
||||
@@ -624,8 +671,14 @@ onMounted(init)
|
||||
|
||||
watch(selectedTab, async (val) => {
|
||||
// navigateTo({ query: { ...route.query, tab: val } }, { replace: true })
|
||||
if (val === 'timeline' && timelineItems.value.length === 0) {
|
||||
await loadTimeline()
|
||||
if (val === 'timeline') {
|
||||
if (timelineFilter.value === 'reads') {
|
||||
if (readPosts.value.length === 0) {
|
||||
await loadReadHistory()
|
||||
}
|
||||
} else if (timelineItems.value.length === 0) {
|
||||
await loadTimeline()
|
||||
}
|
||||
} else if (val === 'following' && followers.value.length === 0 && followings.value.length === 0) {
|
||||
await loadFollow()
|
||||
} else if (val === 'favorites' && favoritePosts.value.length === 0) {
|
||||
@@ -634,6 +687,23 @@ watch(selectedTab, async (val) => {
|
||||
await loadAchievements()
|
||||
}
|
||||
})
|
||||
|
||||
watch(timelineFilter, async (val) => {
|
||||
if (selectedTab.value !== 'timeline') return
|
||||
if (val === 'reads') {
|
||||
if (readPosts.value.length === 0) {
|
||||
await loadReadHistory()
|
||||
}
|
||||
} else if (timelineItems.value.length === 0) {
|
||||
await loadTimeline()
|
||||
}
|
||||
})
|
||||
|
||||
watch(isMine, (val) => {
|
||||
if (!val && timelineFilter.value === 'reads') {
|
||||
timelineFilter.value = 'all'
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
Reference in New Issue
Block a user