mirror of
https://github.com/nagisa77/OpenIsle.git
synced 2026-02-24 15:10:48 +08:00
Implement profile page and related backend APIs
This commit is contained in:
@@ -1,122 +1,175 @@
|
||||
<template>
|
||||
<div class="profile-page">
|
||||
<div class="profile-page-header">
|
||||
<div class="profile-page-header-avatar">
|
||||
<img src="https://via.placeholder.com/150" alt="avatar" class="profile-page-header-avatar-img">
|
||||
</div>
|
||||
<div class="profile-page-header-user-info">
|
||||
<div class="profile-page-header-user-info-name">
|
||||
nagisa
|
||||
<div v-if="isLoading" class="loading-page">
|
||||
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)" />
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<div class="profile-page-header">
|
||||
<div class="profile-page-header-avatar">
|
||||
<img :src="user.avatar" alt="avatar" class="profile-page-header-avatar-img" />
|
||||
</div>
|
||||
<div class="profile-page-header-user-info-description">
|
||||
hello world
|
||||
<div class="profile-page-header-user-info">
|
||||
<div class="profile-page-header-user-info-name">{{ user.username }}</div>
|
||||
<div class="profile-page-header-user-info-description">{{ user.introduction }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="profile-info">
|
||||
<div class="profile-info-item">
|
||||
<div class="profile-info-item-label">加入时间: </div>
|
||||
<div class="profile-info-item-value">2024 年 1月 17 日</div>
|
||||
<div class="profile-info">
|
||||
<div class="profile-info-item">
|
||||
<div class="profile-info-item-label">加入时间:</div>
|
||||
<div class="profile-info-item-value">{{ formatDate(user.createdAt) }}</div>
|
||||
</div>
|
||||
<div class="profile-info-item">
|
||||
<div class="profile-info-item-label">最后发帖时间:</div>
|
||||
<div class="profile-info-item-value">{{ formatDate(user.lastPostTime) }}</div>
|
||||
</div>
|
||||
<div class="profile-info-item">
|
||||
<div class="profile-info-item-label">浏览量:</div>
|
||||
<div class="profile-info-item-value">{{ user.totalViews }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="profile-info-item">
|
||||
<div class="profile-info-item-label">最后发帖时间: </div>
|
||||
<div class="profile-info-item-value">2024 年 2月 17 日</div>
|
||||
<div class="profile-tabs">
|
||||
<div :class="['profile-tabs-item', { selected: selectedTab === 'summary' }]" @click="selectedTab = 'summary'">
|
||||
<i class="fas fa-chart-line"></i>
|
||||
<div class="profile-tabs-item-label">总结</div>
|
||||
</div>
|
||||
<div :class="['profile-tabs-item', { selected: selectedTab === 'timeline' }]" @click="selectedTab = 'timeline'">
|
||||
<i class="fas fa-clock"></i>
|
||||
<div class="profile-tabs-item-label">时间线</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="profile-info-item">
|
||||
<div class="profile-info-item-label">浏览量: </div>
|
||||
<div class="profile-info-item-value">11662</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="profile-tabs">
|
||||
<div class="profile-tabs-item selected">
|
||||
<i class="fas fa-chart-line"></i>
|
||||
<div class="profile-tabs-item-label">总结</div>
|
||||
</div>
|
||||
<div class="profile-tabs-item">
|
||||
<i class="fas fa-clock"></i>
|
||||
<div class="profile-tabs-item-label">时间线</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="profile-summary">
|
||||
<div class="total-summary">
|
||||
<div class="summary-title">统计信息</div>
|
||||
<div class="total-summary-content">
|
||||
<div class="total-summary-item">
|
||||
<div class="total-summary-item-label">访问天数</div>
|
||||
<div class="total-summary-item-value">0</div>
|
||||
<div v-if="selectedTab === 'summary'" class="profile-summary">
|
||||
<div class="summary-divider">
|
||||
<div class="hot-reply">
|
||||
<div class="summary-title">热门回复</div>
|
||||
<BaseTimeline :items="hotReplies" />
|
||||
</div>
|
||||
<div class="total-summary-item">
|
||||
<div class="total-summary-item-label">已读帖子</div>
|
||||
<div class="total-summary-item-value">165k</div>
|
||||
</div>
|
||||
<div class="total-summary-item">
|
||||
<div class="total-summary-item-label">已送出</div>
|
||||
<div class="total-summary-item-value">165k</div>
|
||||
</div>
|
||||
<div class="total-summary-item">
|
||||
<div class="total-summary-item-label">已收到</div>
|
||||
<div class="total-summary-item-value">165k</div>
|
||||
<div class="hot-topic">
|
||||
<div class="summary-title">热门话题</div>
|
||||
<BaseTimeline :items="hotPosts" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="summary-divider">
|
||||
<div class="hot-reply">
|
||||
<div class="summary-title">热门回复</div>
|
||||
</div>
|
||||
|
||||
<div class="hot-topic">
|
||||
<div class="summary-title">热门话题</div>
|
||||
</div>
|
||||
<div v-else class="profile-timeline">
|
||||
<BaseTimeline :items="timelineItems" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="profile-timeline"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { API_BASE_URL } from '../main'
|
||||
import BaseTimeline from '../components/BaseTimeline.vue'
|
||||
import { hatch } from 'ldrs'
|
||||
hatch.register()
|
||||
|
||||
export default {
|
||||
name: 'ProfileView'
|
||||
name: 'ProfileView',
|
||||
components: { BaseTimeline },
|
||||
setup() {
|
||||
const route = useRoute()
|
||||
const username = route.params.id
|
||||
|
||||
const user = ref({})
|
||||
const hotPosts = ref([])
|
||||
const hotReplies = ref([])
|
||||
const timelineItems = ref([])
|
||||
const isLoading = ref(false)
|
||||
const selectedTab = ref('summary')
|
||||
|
||||
const formatDate = (d) => {
|
||||
if (!d) return ''
|
||||
return new Date(d).toLocaleDateString('zh-CN', { year: 'numeric', month: 'numeric', day: 'numeric' })
|
||||
}
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
isLoading.value = true
|
||||
let res = await fetch(`${API_BASE_URL}/api/users/${username}`)
|
||||
if (res.ok) user.value = await res.json()
|
||||
|
||||
res = await fetch(`${API_BASE_URL}/api/users/${username}/hot-posts`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
hotPosts.value = data.map(p => ({
|
||||
icon: 'fas fa-book',
|
||||
content: p.title
|
||||
}))
|
||||
}
|
||||
|
||||
res = await fetch(`${API_BASE_URL}/api/users/${username}/hot-replies`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
hotReplies.value = data.map(c => ({
|
||||
icon: 'fas fa-comment',
|
||||
content: c.content
|
||||
}))
|
||||
}
|
||||
|
||||
const postsRes = await fetch(`${API_BASE_URL}/api/users/${username}/posts?limit=50`)
|
||||
const repliesRes = await fetch(`${API_BASE_URL}/api/users/${username}/replies?limit=50`)
|
||||
const posts = postsRes.ok ? await postsRes.json() : []
|
||||
const replies = repliesRes.ok ? await repliesRes.json() : []
|
||||
const mapped = [
|
||||
...posts.map(p => ({ icon: 'fas fa-book', content: p.title, createdAt: p.createdAt })),
|
||||
...replies.map(r => ({ icon: 'fas fa-comment', content: r.content, createdAt: r.createdAt }))
|
||||
]
|
||||
mapped.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
|
||||
timelineItems.value = mapped
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(fetchData)
|
||||
return { user, hotPosts, hotReplies, timelineItems, isLoading, selectedTab, formatDate }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.loading-page {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 300px;
|
||||
}
|
||||
.profile-page {
|
||||
background-color: var(--background-color);
|
||||
height: calc(100vh - var(--header-height));
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.profile-page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.profile-page-header-avatar-img {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
border-radius: 50%;
|
||||
background-color: lightblue;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.profile-page-header-user-info {
|
||||
margin-left: 20px;
|
||||
}
|
||||
|
||||
.profile-page-header-user-info-name {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.profile-page-header-user-info-description {
|
||||
font-size: 20px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.profile-info {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
@@ -125,7 +178,6 @@ export default {
|
||||
border-top: 1px solid #e0e0e0;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.profile-info-item {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
@@ -133,23 +185,19 @@ export default {
|
||||
align-items: center;
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.profile-info-item-label {
|
||||
font-size: 14px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.profile-info-item-value {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.profile-tabs {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
padding: 0 20px;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.profile-tabs-item {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
@@ -160,52 +208,20 @@ export default {
|
||||
width: 200px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.profile-tabs-item.selected {
|
||||
color: var(--primary-color);
|
||||
border-bottom: 2px solid var(--primary-color);
|
||||
}
|
||||
|
||||
.profile-summary {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 20px;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.total-summary {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.summary-title {
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.total-summary-content {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.total-summary-item {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.total-summary-item-label {
|
||||
font-size: 18px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.total-summary-item-value {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.summary-divider {
|
||||
margin-top: 20px;
|
||||
display: flex;
|
||||
@@ -213,10 +229,8 @@ export default {
|
||||
gap: 20px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.hot-reply,
|
||||
.hot-topic {
|
||||
width: 50%;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -95,6 +95,26 @@ public class UserController {
|
||||
.collect(java.util.stream.Collectors.toList());
|
||||
}
|
||||
|
||||
@GetMapping("/{username}/hot-posts")
|
||||
public java.util.List<PostMetaDto> hotPosts(@PathVariable String username,
|
||||
@RequestParam(value = "limit", required = false) Integer limit) {
|
||||
int l = limit != null ? limit : 10;
|
||||
java.util.List<Long> ids = reactionService.topPostIds(username, l);
|
||||
return postService.getPostsByIds(ids).stream()
|
||||
.map(this::toMetaDto)
|
||||
.collect(java.util.stream.Collectors.toList());
|
||||
}
|
||||
|
||||
@GetMapping("/{username}/hot-replies")
|
||||
public java.util.List<CommentInfoDto> hotReplies(@PathVariable String username,
|
||||
@RequestParam(value = "limit", required = false) Integer limit) {
|
||||
int l = limit != null ? limit : 10;
|
||||
java.util.List<Long> ids = reactionService.topCommentIds(username, l);
|
||||
return commentService.getCommentsByIds(ids).stream()
|
||||
.map(this::toCommentInfoDto)
|
||||
.collect(java.util.stream.Collectors.toList());
|
||||
}
|
||||
|
||||
@GetMapping("/{username}/following")
|
||||
public java.util.List<UserDto> following(@PathVariable String username) {
|
||||
return subscriptionService.getSubscribedUsers(username).stream()
|
||||
@@ -139,6 +159,9 @@ public class UserController {
|
||||
dto.setIntroduction(user.getIntroduction());
|
||||
dto.setFollowers(subscriptionService.countSubscribers(user.getUsername()));
|
||||
dto.setFollowing(subscriptionService.countSubscribed(user.getUsername()));
|
||||
dto.setCreatedAt(user.getCreatedAt());
|
||||
dto.setLastPostTime(postService.getLastPostTime(user.getUsername()));
|
||||
dto.setTotalViews(postService.getTotalViews(user.getUsername()));
|
||||
return dto;
|
||||
}
|
||||
|
||||
@@ -171,6 +194,9 @@ public class UserController {
|
||||
private String introduction;
|
||||
private long followers;
|
||||
private long following;
|
||||
private java.time.LocalDateTime createdAt;
|
||||
private java.time.LocalDateTime lastPostTime;
|
||||
private long totalViews;
|
||||
}
|
||||
|
||||
@Data
|
||||
|
||||
@@ -4,6 +4,7 @@ import jakarta.persistence.*;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import com.openisle.model.Role;
|
||||
|
||||
@@ -43,4 +44,12 @@ public class User {
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false)
|
||||
private Role role = Role.USER;
|
||||
|
||||
@Column(nullable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@PrePersist
|
||||
protected void onCreate() {
|
||||
this.createdAt = LocalDateTime.now();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,9 @@ import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.List;
|
||||
import java.time.LocalDateTime;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
public interface PostRepository extends JpaRepository<Post, Long> {
|
||||
List<Post> findByStatus(PostStatus status);
|
||||
@@ -21,4 +24,10 @@ public interface PostRepository extends JpaRepository<Post, Long> {
|
||||
List<Post> findByTitleContainingIgnoreCaseOrContentContainingIgnoreCaseAndStatus(String titleKeyword, String contentKeyword, PostStatus status);
|
||||
List<Post> findByContentContainingIgnoreCaseAndStatus(String keyword, PostStatus status);
|
||||
List<Post> findByTitleContainingIgnoreCaseAndStatus(String keyword, PostStatus status);
|
||||
|
||||
@Query("SELECT MAX(p.createdAt) FROM Post p WHERE p.author.username = :username AND p.status = com.openisle.model.PostStatus.PUBLISHED")
|
||||
LocalDateTime findLastPostTime(@Param("username") String username);
|
||||
|
||||
@Query("SELECT SUM(p.views) FROM Post p WHERE p.author.username = :username AND p.status = com.openisle.model.PostStatus.PUBLISHED")
|
||||
Long sumViews(@Param("username") String username);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,9 @@ import com.openisle.model.Post;
|
||||
import com.openisle.model.Reaction;
|
||||
import com.openisle.model.User;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
@@ -14,4 +17,10 @@ public interface ReactionRepository extends JpaRepository<Reaction, Long> {
|
||||
Optional<Reaction> findByUserAndComment(User user, Comment comment);
|
||||
List<Reaction> findByPost(Post post);
|
||||
List<Reaction> findByComment(Comment comment);
|
||||
|
||||
@Query("SELECT r.post.id FROM Reaction r WHERE r.post IS NOT NULL AND r.post.author.username = :username AND r.type = com.openisle.model.ReactionType.LIKE GROUP BY r.post.id ORDER BY COUNT(r.id) DESC")
|
||||
List<Long> findTopPostIds(@Param("username") String username, Pageable pageable);
|
||||
|
||||
@Query("SELECT r.comment.id FROM Reaction r WHERE r.comment IS NOT NULL AND r.comment.author.username = :username AND r.type = com.openisle.model.ReactionType.LIKE GROUP BY r.comment.id ORDER BY COUNT(r.id) DESC")
|
||||
List<Long> findTopCommentIds(@Param("username") String username, Pageable pageable);
|
||||
}
|
||||
|
||||
@@ -101,4 +101,8 @@ public class CommentService {
|
||||
Pageable pageable = PageRequest.of(0, limit);
|
||||
return commentRepository.findByAuthorOrderByCreatedAtDesc(user, pageable);
|
||||
}
|
||||
|
||||
public java.util.List<Comment> getCommentsByIds(java.util.List<Long> ids) {
|
||||
return commentRepository.findAllById(ids);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,6 +136,15 @@ public class PostService {
|
||||
return postRepository.findByAuthorAndStatusOrderByCreatedAtDesc(user, PostStatus.PUBLISHED, pageable);
|
||||
}
|
||||
|
||||
public java.time.LocalDateTime getLastPostTime(String username) {
|
||||
return postRepository.findLastPostTime(username);
|
||||
}
|
||||
|
||||
public long getTotalViews(String username) {
|
||||
Long v = postRepository.sumViews(username);
|
||||
return v != null ? v : 0;
|
||||
}
|
||||
|
||||
public List<Post> listPostsByTags(java.util.List<Long> tagIds,
|
||||
Integer page,
|
||||
Integer pageSize) {
|
||||
@@ -180,4 +189,8 @@ public class PostService {
|
||||
notificationService.createNotification(post.getAuthor(), NotificationType.POST_REVIEWED, post, null, false);
|
||||
return post;
|
||||
}
|
||||
|
||||
public java.util.List<Post> getPostsByIds(java.util.List<Long> ids) {
|
||||
return postRepository.findAllById(ids);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,4 +69,12 @@ public class ReactionService {
|
||||
.orElseThrow(() -> new IllegalArgumentException("Comment not found"));
|
||||
return reactionRepository.findByComment(comment);
|
||||
}
|
||||
|
||||
public java.util.List<Long> topPostIds(String username, int limit) {
|
||||
return reactionRepository.findTopPostIds(username, org.springframework.data.domain.PageRequest.of(0, limit));
|
||||
}
|
||||
|
||||
public java.util.List<Long> topCommentIds(String username, int limit) {
|
||||
return reactionRepository.findTopCommentIds(username, org.springframework.data.domain.PageRequest.of(0, limit));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user