mirror of
https://github.com/nagisa77/OpenIsle.git
synced 2026-05-09 12:17:29 +08:00
Merge pull request #193 from nagisa77/codex/add-hot-tag-feature-to-profileview
Add tag timeline and summary features
This commit is contained in:
@@ -136,6 +136,27 @@
|
|||||||
<div class="summary-empty">暂无热门话题</div>
|
<div class="summary-empty">暂无热门话题</div>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -177,6 +198,16 @@
|
|||||||
</router-link>
|
</router-link>
|
||||||
<div class="timeline-date">{{ formatDate(item.createdAt) }}</div>
|
<div class="timeline-date">{{ formatDate(item.createdAt) }}</div>
|
||||||
</template>
|
</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>
|
</template>
|
||||||
</BaseTimeline>
|
</BaseTimeline>
|
||||||
</div>
|
</div>
|
||||||
@@ -203,7 +234,7 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { ref, computed, onMounted, watch } from 'vue'
|
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 { API_BASE_URL, toast } from '../main'
|
||||||
import { getToken, authState } from '../utils/auth'
|
import { getToken, authState } from '../utils/auth'
|
||||||
import BaseTimeline from '../components/BaseTimeline.vue'
|
import BaseTimeline from '../components/BaseTimeline.vue'
|
||||||
@@ -219,11 +250,13 @@ export default {
|
|||||||
components: { BaseTimeline, UserList, BasePlaceholder },
|
components: { BaseTimeline, UserList, BasePlaceholder },
|
||||||
setup() {
|
setup() {
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
const username = route.params.id
|
const username = route.params.id
|
||||||
|
|
||||||
const user = ref({})
|
const user = ref({})
|
||||||
const hotPosts = ref([])
|
const hotPosts = ref([])
|
||||||
const hotReplies = ref([])
|
const hotReplies = ref([])
|
||||||
|
const hotTags = ref([])
|
||||||
const timelineItems = ref([])
|
const timelineItems = ref([])
|
||||||
const followers = ref([])
|
const followers = ref([])
|
||||||
const followings = ref([])
|
const followings = ref([])
|
||||||
@@ -263,13 +296,21 @@ export default {
|
|||||||
const data = await repliesRes.json()
|
const data = await repliesRes.json()
|
||||||
hotReplies.value = data.map(c => ({ icon: 'fas fa-comment', comment: c }))
|
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 fetchTimeline = async () => {
|
||||||
const postsRes = await fetch(`${API_BASE_URL}/api/users/${username}/posts?limit=50`)
|
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 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 posts = postsRes.ok ? await postsRes.json() : []
|
||||||
const replies = repliesRes.ok ? await repliesRes.json() : []
|
const replies = repliesRes.ok ? await repliesRes.json() : []
|
||||||
|
const tags = tagsRes.ok ? await tagsRes.json() : []
|
||||||
const mapped = [
|
const mapped = [
|
||||||
...posts.map(p => ({
|
...posts.map(p => ({
|
||||||
type: 'post',
|
type: 'post',
|
||||||
@@ -282,6 +323,12 @@ export default {
|
|||||||
icon: 'fas fa-comment',
|
icon: 'fas fa-comment',
|
||||||
comment: r,
|
comment: r,
|
||||||
createdAt: r.createdAt
|
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))
|
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 () => {
|
const init = async () => {
|
||||||
try {
|
try {
|
||||||
await fetchUser()
|
await fetchUser()
|
||||||
@@ -388,7 +442,9 @@ export default {
|
|||||||
loadFollow,
|
loadFollow,
|
||||||
loadSummary,
|
loadSummary,
|
||||||
subscribeUser,
|
subscribeUser,
|
||||||
unsubscribeUser
|
unsubscribeUser,
|
||||||
|
gotoTag,
|
||||||
|
hotTags
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -569,8 +625,9 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.hot-reply,
|
.hot-reply,
|
||||||
.hot-topic {
|
.hot-topic,
|
||||||
width: 50%;
|
.hot-tag {
|
||||||
|
width: 33%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-timeline {
|
.profile-timeline {
|
||||||
|
|||||||
@@ -30,7 +30,13 @@ public class TagController {
|
|||||||
approved = false;
|
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());
|
long count = postService.countPostsByTag(tag.getId());
|
||||||
return toDto(tag, count);
|
return toDto(tag, count);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ public class UserController {
|
|||||||
private final PostService postService;
|
private final PostService postService;
|
||||||
private final CommentService commentService;
|
private final CommentService commentService;
|
||||||
private final ReactionService reactionService;
|
private final ReactionService reactionService;
|
||||||
|
private final TagService tagService;
|
||||||
private final SubscriptionService subscriptionService;
|
private final SubscriptionService subscriptionService;
|
||||||
private final PostReadService postReadService;
|
private final PostReadService postReadService;
|
||||||
private final UserVisitService userVisitService;
|
private final UserVisitService userVisitService;
|
||||||
@@ -38,6 +39,9 @@ public class UserController {
|
|||||||
@Value("${app.user.replies-limit:50}")
|
@Value("${app.user.replies-limit:50}")
|
||||||
private int defaultRepliesLimit;
|
private int defaultRepliesLimit;
|
||||||
|
|
||||||
|
@Value("${app.user.tags-limit:50}")
|
||||||
|
private int defaultTagsLimit;
|
||||||
|
|
||||||
@Value("${app.snippet-length:50}")
|
@Value("${app.snippet-length:50}")
|
||||||
private int snippetLength;
|
private int snippetLength;
|
||||||
|
|
||||||
@@ -122,6 +126,48 @@ public class UserController {
|
|||||||
.collect(java.util.stream.Collectors.toList());
|
.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")
|
@GetMapping("/{identifier}/following")
|
||||||
public java.util.List<UserDto> following(@PathVariable("identifier") String identifier) {
|
public java.util.List<UserDto> following(@PathVariable("identifier") String identifier) {
|
||||||
User user = userService.findByIdentifier(identifier).orElseThrow();
|
User user = userService.findByIdentifier(identifier).orElseThrow();
|
||||||
@@ -262,6 +308,17 @@ public class UserController {
|
|||||||
private ParentCommentDto parentComment;
|
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
|
@Data
|
||||||
private static class ParentCommentDto {
|
private static class ParentCommentDto {
|
||||||
private Long id;
|
private Long id;
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ import jakarta.persistence.*;
|
|||||||
import lombok.Getter;
|
import lombok.Getter;
|
||||||
import lombok.NoArgsConstructor;
|
import lombok.NoArgsConstructor;
|
||||||
import lombok.Setter;
|
import lombok.Setter;
|
||||||
|
import org.hibernate.annotations.CreationTimestamp;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
|
||||||
@Entity
|
@Entity
|
||||||
@Getter
|
@Getter
|
||||||
@@ -29,4 +33,13 @@ public class Tag {
|
|||||||
|
|
||||||
@Column(nullable = false)
|
@Column(nullable = false)
|
||||||
private boolean approved = true;
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package com.openisle.repository;
|
package com.openisle.repository;
|
||||||
|
|
||||||
import com.openisle.model.Tag;
|
import com.openisle.model.Tag;
|
||||||
|
import com.openisle.model.User;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.domain.Pageable;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
@@ -10,4 +12,7 @@ public interface TagRepository extends JpaRepository<Tag, Long> {
|
|||||||
List<Tag> findByApproved(boolean approved);
|
List<Tag> findByApproved(boolean approved);
|
||||||
List<Tag> findByApprovedTrue();
|
List<Tag> findByApprovedTrue();
|
||||||
List<Tag> findByNameContainingIgnoreCaseAndApprovedTrue(String keyword);
|
List<Tag> findByNameContainingIgnoreCaseAndApprovedTrue(String keyword);
|
||||||
|
|
||||||
|
List<Tag> findByCreatorOrderByCreatedAtDesc(User creator, Pageable pageable);
|
||||||
|
List<Tag> findByCreator(User creator);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
package com.openisle.service;
|
package com.openisle.service;
|
||||||
|
|
||||||
import com.openisle.model.Tag;
|
import com.openisle.model.Tag;
|
||||||
|
import com.openisle.model.User;
|
||||||
import com.openisle.repository.TagRepository;
|
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 lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
@@ -12,8 +16,9 @@ import java.util.List;
|
|||||||
public class TagService {
|
public class TagService {
|
||||||
private final TagRepository tagRepository;
|
private final TagRepository tagRepository;
|
||||||
private final TagValidator tagValidator;
|
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);
|
tagValidator.validate(name);
|
||||||
Tag tag = new Tag();
|
Tag tag = new Tag();
|
||||||
tag.setName(name);
|
tag.setName(name);
|
||||||
@@ -21,11 +26,20 @@ public class TagService {
|
|||||||
tag.setIcon(icon);
|
tag.setIcon(icon);
|
||||||
tag.setSmallIcon(smallIcon);
|
tag.setSmallIcon(smallIcon);
|
||||||
tag.setApproved(approved);
|
tag.setApproved(approved);
|
||||||
|
if (creatorUsername != null) {
|
||||||
|
User creator = userRepository.findByUsername(creatorUsername)
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException("User not found"));
|
||||||
|
tag.setCreator(creator);
|
||||||
|
}
|
||||||
return tagRepository.save(tag);
|
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) {
|
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) {
|
public Tag updateTag(Long id, String name, String description, String icon, String smallIcon) {
|
||||||
@@ -77,4 +91,17 @@ public class TagService {
|
|||||||
}
|
}
|
||||||
return tagRepository.findByNameContainingIgnoreCaseAndApprovedTrue(keyword);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import org.springframework.test.web.servlet.MockMvc;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
import static org.mockito.ArgumentMatchers.eq;
|
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.request.MockMvcRequestBuilders.*;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
||||||
|
|
||||||
@@ -38,7 +39,7 @@ class TagControllerTest {
|
|||||||
t.setDescription("d");
|
t.setDescription("d");
|
||||||
t.setIcon("i");
|
t.setIcon("i");
|
||||||
t.setSmallIcon("s1");
|
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);
|
Mockito.when(tagService.getTag(1L)).thenReturn(t);
|
||||||
|
|
||||||
mockMvc.perform(post("/api/tags")
|
mockMvc.perform(post("/api/tags")
|
||||||
|
|||||||
Reference in New Issue
Block a user