Merge pull request #193 from nagisa77/codex/add-hot-tag-feature-to-profileview

Add tag timeline and summary features
This commit is contained in:
Tim
2025-07-14 13:45:26 +08:00
committed by GitHub
7 changed files with 174 additions and 8 deletions

View File

@@ -136,6 +136,27 @@
<div class="summary-empty">暂无热门话题</div>
</div>
</div>
<div class="hot-tag">
<div class="summary-title">TA创建的tag</div>
<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>
</template>
</BaseTimeline>
</div>
<div v-else>
<div class="summary-empty">暂无标签</div>
</div>
</div>
</div>
</div>
@@ -177,6 +198,16 @@
</router-link>
<div class="timeline-date">{{ formatDate(item.createdAt) }}</div>
</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>
</template>
</template>
</BaseTimeline>
</div>
@@ -203,7 +234,7 @@
<script>
import { ref, computed, onMounted, watch } from 'vue'
import { useRoute } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import { API_BASE_URL, toast } from '../main'
import { getToken, authState } from '../utils/auth'
import BaseTimeline from '../components/BaseTimeline.vue'
@@ -219,11 +250,13 @@ export default {
components: { BaseTimeline, UserList, BasePlaceholder },
setup() {
const route = useRoute()
const router = useRouter()
const username = route.params.id
const user = ref({})
const hotPosts = ref([])
const hotReplies = ref([])
const hotTags = ref([])
const timelineItems = ref([])
const followers = ref([])
const followings = ref([])
@@ -263,13 +296,21 @@ export default {
const data = await repliesRes.json()
hotReplies.value = data.map(c => ({ icon: 'fas fa-comment', comment: c }))
}
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: 'fas fa-tag', tag: t }))
}
}
const fetchTimeline = async () => {
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 tagsRes = await fetch(`${API_BASE_URL}/api/users/${username}/tags?limit=50`)
const posts = postsRes.ok ? await postsRes.json() : []
const replies = repliesRes.ok ? await repliesRes.json() : []
const tags = tagsRes.ok ? await tagsRes.json() : []
const mapped = [
...posts.map(p => ({
type: 'post',
@@ -282,6 +323,12 @@ export default {
icon: 'fas fa-comment',
comment: r,
createdAt: r.createdAt
})),
...tags.map(t => ({
type: 'tag',
icon: 'fas fa-tag',
tag: t,
createdAt: t.createdAt
}))
]
mapped.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
@@ -349,6 +396,13 @@ export default {
}
}
const gotoTag = tag => {
const value = encodeURIComponent(tag.id ?? tag.name)
router.push({ path: '/', query: { tags: value } }).then(() => {
window.location.reload()
})
}
const init = async () => {
try {
await fetchUser()
@@ -388,7 +442,9 @@ export default {
loadFollow,
loadSummary,
subscribeUser,
unsubscribeUser
unsubscribeUser,
gotoTag,
hotTags
}
}
}
@@ -569,8 +625,9 @@ export default {
}
.hot-reply,
.hot-topic {
width: 50%;
.hot-topic,
.hot-tag {
width: 33%;
}
.profile-timeline {

View File

@@ -30,7 +30,13 @@ public class TagController {
approved = false;
}
}
Tag tag = tagService.createTag(req.getName(), req.getDescription(), req.getIcon(), req.getSmallIcon(), approved);
Tag tag = tagService.createTag(
req.getName(),
req.getDescription(),
req.getIcon(),
req.getSmallIcon(),
approved,
auth != null ? auth.getName() : null);
long count = postService.countPostsByTag(tag.getId());
return toDto(tag, count);
}

View File

@@ -22,6 +22,7 @@ public class UserController {
private final PostService postService;
private final CommentService commentService;
private final ReactionService reactionService;
private final TagService tagService;
private final SubscriptionService subscriptionService;
private final PostReadService postReadService;
private final UserVisitService userVisitService;
@@ -38,6 +39,9 @@ public class UserController {
@Value("${app.user.replies-limit:50}")
private int defaultRepliesLimit;
@Value("${app.user.tags-limit:50}")
private int defaultTagsLimit;
@Value("${app.snippet-length:50}")
private int snippetLength;
@@ -122,6 +126,48 @@ public class UserController {
.collect(java.util.stream.Collectors.toList());
}
@GetMapping("/{identifier}/hot-tags")
public java.util.List<TagInfoDto> hotTags(@PathVariable("identifier") String identifier,
@RequestParam(value = "limit", required = false) Integer limit) {
int l = limit != null ? limit : 10;
User user = userService.findByIdentifier(identifier).orElseThrow();
return tagService.getTagsByUser(user.getUsername()).stream()
.map(t -> {
TagInfoDto dto = new TagInfoDto();
dto.setId(t.getId());
dto.setName(t.getName());
dto.setDescription(t.getDescription());
dto.setIcon(t.getIcon());
dto.setSmallIcon(t.getSmallIcon());
dto.setCreatedAt(t.getCreatedAt());
dto.setCount(postService.countPostsByTag(t.getId()));
return dto;
})
.sorted((a, b) -> Long.compare(b.getCount(), a.getCount()))
.limit(l)
.collect(java.util.stream.Collectors.toList());
}
@GetMapping("/{identifier}/tags")
public java.util.List<TagInfoDto> userTags(@PathVariable("identifier") String identifier,
@RequestParam(value = "limit", required = false) Integer limit) {
int l = limit != null ? limit : defaultTagsLimit;
User user = userService.findByIdentifier(identifier).orElseThrow();
return tagService.getRecentTagsByUser(user.getUsername(), l).stream()
.map(t -> {
TagInfoDto dto = new TagInfoDto();
dto.setId(t.getId());
dto.setName(t.getName());
dto.setDescription(t.getDescription());
dto.setIcon(t.getIcon());
dto.setSmallIcon(t.getSmallIcon());
dto.setCreatedAt(t.getCreatedAt());
dto.setCount(postService.countPostsByTag(t.getId()));
return dto;
})
.collect(java.util.stream.Collectors.toList());
}
@GetMapping("/{identifier}/following")
public java.util.List<UserDto> following(@PathVariable("identifier") String identifier) {
User user = userService.findByIdentifier(identifier).orElseThrow();
@@ -262,6 +308,17 @@ public class UserController {
private ParentCommentDto parentComment;
}
@Data
private static class TagInfoDto {
private Long id;
private String name;
private String description;
private String icon;
private String smallIcon;
private java.time.LocalDateTime createdAt;
private Long count;
}
@Data
private static class ParentCommentDto {
private Long id;

View File

@@ -4,6 +4,10 @@ import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.hibernate.annotations.CreationTimestamp;
import java.time.LocalDateTime;
@Entity
@Getter
@@ -29,4 +33,13 @@ public class Tag {
@Column(nullable = false)
private boolean approved = true;
@CreationTimestamp
@Column(nullable = false, updatable = false,
columnDefinition = "DATETIME(6) DEFAULT CURRENT_TIMESTAMP(6)")
private LocalDateTime createdAt;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "creator_id")
private User creator;
}

View File

@@ -1,7 +1,9 @@
package com.openisle.repository;
import com.openisle.model.Tag;
import com.openisle.model.User;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.domain.Pageable;
import java.util.List;
@@ -10,4 +12,7 @@ public interface TagRepository extends JpaRepository<Tag, Long> {
List<Tag> findByApproved(boolean approved);
List<Tag> findByApprovedTrue();
List<Tag> findByNameContainingIgnoreCaseAndApprovedTrue(String keyword);
List<Tag> findByCreatorOrderByCreatedAtDesc(User creator, Pageable pageable);
List<Tag> findByCreator(User creator);
}

View File

@@ -1,7 +1,11 @@
package com.openisle.service;
import com.openisle.model.Tag;
import com.openisle.model.User;
import com.openisle.repository.TagRepository;
import com.openisle.repository.UserRepository;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
@@ -12,8 +16,9 @@ import java.util.List;
public class TagService {
private final TagRepository tagRepository;
private final TagValidator tagValidator;
private final UserRepository userRepository;
public Tag createTag(String name, String description, String icon, String smallIcon, boolean approved) {
public Tag createTag(String name, String description, String icon, String smallIcon, boolean approved, String creatorUsername) {
tagValidator.validate(name);
Tag tag = new Tag();
tag.setName(name);
@@ -21,11 +26,20 @@ public class TagService {
tag.setIcon(icon);
tag.setSmallIcon(smallIcon);
tag.setApproved(approved);
if (creatorUsername != null) {
User creator = userRepository.findByUsername(creatorUsername)
.orElseThrow(() -> new IllegalArgumentException("User not found"));
tag.setCreator(creator);
}
return tagRepository.save(tag);
}
public Tag createTag(String name, String description, String icon, String smallIcon, boolean approved) {
return createTag(name, description, icon, smallIcon, approved, null);
}
public Tag createTag(String name, String description, String icon, String smallIcon) {
return createTag(name, description, icon, smallIcon, true);
return createTag(name, description, icon, smallIcon, true, null);
}
public Tag updateTag(Long id, String name, String description, String icon, String smallIcon) {
@@ -77,4 +91,17 @@ public class TagService {
}
return tagRepository.findByNameContainingIgnoreCaseAndApprovedTrue(keyword);
}
public List<Tag> getRecentTagsByUser(String username, int limit) {
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new IllegalArgumentException("User not found"));
Pageable pageable = PageRequest.of(0, limit);
return tagRepository.findByCreatorOrderByCreatedAtDesc(user, pageable);
}
public List<Tag> getTagsByUser(String username) {
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new IllegalArgumentException("User not found"));
return tagRepository.findByCreator(user);
}
}

View File

@@ -15,6 +15,7 @@ import org.springframework.test.web.servlet.MockMvc;
import java.util.List;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@@ -38,7 +39,7 @@ class TagControllerTest {
t.setDescription("d");
t.setIcon("i");
t.setSmallIcon("s1");
Mockito.when(tagService.createTag(eq("java"), eq("d"), eq("i"), eq("s1"))).thenReturn(t);
Mockito.when(tagService.createTag(eq("java"), eq("d"), eq("i"), eq("s1"), eq(true), isNull())).thenReturn(t);
Mockito.when(tagService.getTag(1L)).thenReturn(t);
mockMvc.perform(post("/api/tags")