mirror of
https://github.com/nagisa77/OpenIsle.git
synced 2026-02-24 23:20:49 +08:00
Compare commits
28 Commits
codex/add-
...
codex/crea
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db9b48c3a4 | ||
|
|
3eabafadf8 | ||
|
|
62c1983fd5 | ||
|
|
689b719e18 | ||
|
|
c6eccb01b9 | ||
|
|
cdf7e61157 | ||
|
|
d23511ecb9 | ||
|
|
c76708d2ff | ||
|
|
d978bd428e | ||
|
|
e5954cfb62 | ||
|
|
cb614b9739 | ||
|
|
88ce6b682d | ||
|
|
e02db635c4 | ||
|
|
231379181a | ||
|
|
bd9ce67d4b | ||
|
|
6527b3790e | ||
|
|
f01e8c942a | ||
|
|
1e1ae29d32 | ||
|
|
d31a8bfee4 | ||
|
|
29a96595f7 | ||
|
|
2b242367d7 | ||
|
|
3f0cd2bf0f | ||
|
|
a98a631378 | ||
|
|
7701d359dc | ||
|
|
ffd9ef8a32 | ||
|
|
36cd5ab171 | ||
|
|
ac3fc6702a | ||
|
|
b0eef220a6 |
@@ -41,7 +41,8 @@ public class PostController {
|
|||||||
Post post = postService.createPost(auth.getName(), req.getCategoryId(),
|
Post post = postService.createPost(auth.getName(), req.getCategoryId(),
|
||||||
req.getTitle(), req.getContent(), req.getTagIds(),
|
req.getTitle(), req.getContent(), req.getTagIds(),
|
||||||
req.getType(), req.getPrizeDescription(), req.getPrizeIcon(),
|
req.getType(), req.getPrizeDescription(), req.getPrizeIcon(),
|
||||||
req.getPrizeCount(), req.getStartTime(), req.getEndTime());
|
req.getPrizeCount(), req.getPointCost(),
|
||||||
|
req.getStartTime(), req.getEndTime());
|
||||||
draftService.deleteDraft(auth.getName());
|
draftService.deleteDraft(auth.getName());
|
||||||
PostDetailDto dto = postMapper.toDetailDto(post, auth.getName());
|
PostDetailDto dto = postMapper.toDetailDto(post, auth.getName());
|
||||||
dto.setReward(levelService.awardForPost(auth.getName()));
|
dto.setReward(levelService.awardForPost(auth.getName()));
|
||||||
|
|||||||
@@ -105,6 +105,17 @@ public class UserController {
|
|||||||
.collect(java.util.stream.Collectors.toList());
|
.collect(java.util.stream.Collectors.toList());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{identifier}/subscribed-posts")
|
||||||
|
public java.util.List<PostMetaDto> subscribedPosts(@PathVariable("identifier") String identifier,
|
||||||
|
@RequestParam(value = "limit", required = false) Integer limit) {
|
||||||
|
int l = limit != null ? limit : defaultPostsLimit;
|
||||||
|
User user = userService.findByIdentifier(identifier).orElseThrow();
|
||||||
|
return subscriptionService.getSubscribedPosts(user.getUsername()).stream()
|
||||||
|
.limit(l)
|
||||||
|
.map(userMapper::toMetaDto)
|
||||||
|
.collect(java.util.stream.Collectors.toList());
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping("/{identifier}/replies")
|
@GetMapping("/{identifier}/replies")
|
||||||
public java.util.List<CommentInfoDto> userReplies(@PathVariable("identifier") String identifier,
|
public java.util.List<CommentInfoDto> userReplies(@PathVariable("identifier") String identifier,
|
||||||
@RequestParam(value = "limit", required = false) Integer limit) {
|
@RequestParam(value = "limit", required = false) Integer limit) {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ public class LotteryDto {
|
|||||||
private String prizeDescription;
|
private String prizeDescription;
|
||||||
private String prizeIcon;
|
private String prizeIcon;
|
||||||
private int prizeCount;
|
private int prizeCount;
|
||||||
|
private int pointCost;
|
||||||
private LocalDateTime startTime;
|
private LocalDateTime startTime;
|
||||||
private LocalDateTime endTime;
|
private LocalDateTime endTime;
|
||||||
private List<AuthorDto> participants;
|
private List<AuthorDto> participants;
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ public class PostRequest {
|
|||||||
private String prizeDescription;
|
private String prizeDescription;
|
||||||
private String prizeIcon;
|
private String prizeIcon;
|
||||||
private Integer prizeCount;
|
private Integer prizeCount;
|
||||||
|
private Integer pointCost;
|
||||||
private LocalDateTime startTime;
|
private LocalDateTime startTime;
|
||||||
private LocalDateTime endTime;
|
private LocalDateTime endTime;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ public class PostMapper {
|
|||||||
l.setPrizeDescription(lp.getPrizeDescription());
|
l.setPrizeDescription(lp.getPrizeDescription());
|
||||||
l.setPrizeIcon(lp.getPrizeIcon());
|
l.setPrizeIcon(lp.getPrizeIcon());
|
||||||
l.setPrizeCount(lp.getPrizeCount());
|
l.setPrizeCount(lp.getPrizeCount());
|
||||||
|
l.setPointCost(lp.getPointCost());
|
||||||
l.setStartTime(lp.getStartTime());
|
l.setStartTime(lp.getStartTime());
|
||||||
l.setEndTime(lp.getEndTime());
|
l.setEndTime(lp.getEndTime());
|
||||||
l.setParticipants(lp.getParticipants().stream().map(userMapper::toAuthorDto).collect(Collectors.toList()));
|
l.setParticipants(lp.getParticipants().stream().map(userMapper::toAuthorDto).collect(Collectors.toList()));
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ public class LotteryPost extends Post {
|
|||||||
@Column(nullable = false)
|
@Column(nullable = false)
|
||||||
private int prizeCount;
|
private int prizeCount;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private int pointCost;
|
||||||
|
|
||||||
@Column
|
@Column
|
||||||
private LocalDateTime startTime;
|
private LocalDateTime startTime;
|
||||||
|
|
||||||
|
|||||||
@@ -8,5 +8,7 @@ public enum PointHistoryType {
|
|||||||
INVITE,
|
INVITE,
|
||||||
FEATURE,
|
FEATURE,
|
||||||
SYSTEM_ONLINE,
|
SYSTEM_ONLINE,
|
||||||
REDEEM
|
REDEEM,
|
||||||
|
LOTTERY_JOIN,
|
||||||
|
LOTTERY_REWARD
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.openisle.service;
|
|||||||
|
|
||||||
import com.openisle.model.*;
|
import com.openisle.model.*;
|
||||||
import com.openisle.repository.*;
|
import com.openisle.repository.*;
|
||||||
|
import com.openisle.exception.FieldException;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
@@ -39,6 +40,17 @@ public class PointService {
|
|||||||
return addPoint(user, 500, PointHistoryType.FEATURE, post, null, null);
|
return addPoint(user, 500, PointHistoryType.FEATURE, post, null, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void processLotteryJoin(User participant, LotteryPost post) {
|
||||||
|
int cost = post.getPointCost();
|
||||||
|
if (cost > 0) {
|
||||||
|
if (participant.getPoint() < cost) {
|
||||||
|
throw new FieldException("point", "积分不足");
|
||||||
|
}
|
||||||
|
addPoint(participant, -cost, PointHistoryType.LOTTERY_JOIN, post, null, post.getAuthor());
|
||||||
|
addPoint(post.getAuthor(), cost, PointHistoryType.LOTTERY_REWARD, post, null, participant);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private PointLog getTodayLog(User user) {
|
private PointLog getTodayLog(User user) {
|
||||||
LocalDate today = LocalDate.now();
|
LocalDate today = LocalDate.now();
|
||||||
return pointLogRepository.findByUserAndLogDate(user, today)
|
return pointLogRepository.findByUserAndLogDate(user, today)
|
||||||
|
|||||||
@@ -164,6 +164,7 @@ public class PostService {
|
|||||||
String prizeDescription,
|
String prizeDescription,
|
||||||
String prizeIcon,
|
String prizeIcon,
|
||||||
Integer prizeCount,
|
Integer prizeCount,
|
||||||
|
Integer pointCost,
|
||||||
LocalDateTime startTime,
|
LocalDateTime startTime,
|
||||||
LocalDateTime endTime) {
|
LocalDateTime endTime) {
|
||||||
long recent = postRepository.countByAuthorAfter(username,
|
long recent = postRepository.countByAuthorAfter(username,
|
||||||
@@ -188,10 +189,14 @@ public class PostService {
|
|||||||
PostType actualType = type != null ? type : PostType.NORMAL;
|
PostType actualType = type != null ? type : PostType.NORMAL;
|
||||||
Post post;
|
Post post;
|
||||||
if (actualType == PostType.LOTTERY) {
|
if (actualType == PostType.LOTTERY) {
|
||||||
|
if (pointCost != null && (pointCost < 0 || pointCost > 100)) {
|
||||||
|
throw new IllegalArgumentException("pointCost must be between 0 and 100");
|
||||||
|
}
|
||||||
LotteryPost lp = new LotteryPost();
|
LotteryPost lp = new LotteryPost();
|
||||||
lp.setPrizeDescription(prizeDescription);
|
lp.setPrizeDescription(prizeDescription);
|
||||||
lp.setPrizeIcon(prizeIcon);
|
lp.setPrizeIcon(prizeIcon);
|
||||||
lp.setPrizeCount(prizeCount != null ? prizeCount : 0);
|
lp.setPrizeCount(prizeCount != null ? prizeCount : 0);
|
||||||
|
lp.setPointCost(pointCost != null ? pointCost : 0);
|
||||||
lp.setStartTime(startTime);
|
lp.setStartTime(startTime);
|
||||||
lp.setEndTime(endTime);
|
lp.setEndTime(endTime);
|
||||||
post = lp;
|
post = lp;
|
||||||
@@ -250,8 +255,10 @@ public class PostService {
|
|||||||
.orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found"));
|
.orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found"));
|
||||||
User user = userRepository.findByUsername(username)
|
User user = userRepository.findByUsername(username)
|
||||||
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
|
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
|
||||||
post.getParticipants().add(user);
|
if (post.getParticipants().add(user)) {
|
||||||
lotteryPostRepository.save(post);
|
pointService.processLotteryJoin(user, post);
|
||||||
|
lotteryPostRepository.save(post);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
|
|||||||
@@ -107,6 +107,11 @@ public class SubscriptionService {
|
|||||||
return commentSubRepo.findByComment(c).stream().map(CommentSubscription::getUser).toList();
|
return commentSubRepo.findByComment(c).stream().map(CommentSubscription::getUser).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public List<Post> getSubscribedPosts(String username) {
|
||||||
|
User user = userRepo.findByUsername(username).orElseThrow();
|
||||||
|
return postSubRepo.findByUser(user).stream().map(PostSubscription::getPost).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
public long countSubscribers(String username) {
|
public long countSubscribers(String username) {
|
||||||
User user = userRepo.findByUsername(username).orElseThrow();
|
User user = userRepo.findByUsername(username).orElseThrow();
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE lottery_posts ADD COLUMN point_cost INT NOT NULL DEFAULT 0;
|
||||||
@@ -76,7 +76,7 @@ class PostControllerTest {
|
|||||||
post.setTags(Set.of(tag));
|
post.setTags(Set.of(tag));
|
||||||
|
|
||||||
when(postService.createPost(eq("alice"), eq(1L), eq("t"), eq("c"), eq(List.of(1L)),
|
when(postService.createPost(eq("alice"), eq(1L), eq("t"), eq("c"), eq(List.of(1L)),
|
||||||
isNull(), isNull(), isNull(), isNull(), isNull(), isNull())).thenReturn(post);
|
isNull(), isNull(), isNull(), isNull(), isNull(), isNull(), isNull())).thenReturn(post);
|
||||||
when(postService.viewPost(eq(1L), any())).thenReturn(post);
|
when(postService.viewPost(eq(1L), any())).thenReturn(post);
|
||||||
when(commentService.getCommentsForPost(eq(1L), any())).thenReturn(List.of());
|
when(commentService.getCommentsForPost(eq(1L), any())).thenReturn(List.of());
|
||||||
when(commentService.getParticipants(anyLong(), anyInt())).thenReturn(List.of());
|
when(commentService.getParticipants(anyLong(), anyInt())).thenReturn(List.of());
|
||||||
@@ -187,7 +187,7 @@ class PostControllerTest {
|
|||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
|
|
||||||
verify(postService, never()).createPost(any(), any(), any(), any(), any(),
|
verify(postService, never()).createPost(any(), any(), any(), any(), any(),
|
||||||
any(), any(), any(), any(), any(), any());
|
any(), any(), any(), any(), any(), any(), any());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -136,6 +136,30 @@ class UserControllerTest {
|
|||||||
.andExpect(jsonPath("$[0].title").value("hello"));
|
.andExpect(jsonPath("$[0].title").value("hello"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void listSubscribedPosts() throws Exception {
|
||||||
|
User user = new User();
|
||||||
|
user.setUsername("bob");
|
||||||
|
com.openisle.model.Category cat = new com.openisle.model.Category();
|
||||||
|
cat.setName("tech");
|
||||||
|
com.openisle.model.Post post = new com.openisle.model.Post();
|
||||||
|
post.setId(6L);
|
||||||
|
post.setTitle("fav");
|
||||||
|
post.setCreatedAt(java.time.LocalDateTime.now());
|
||||||
|
post.setCategory(cat);
|
||||||
|
post.setAuthor(user);
|
||||||
|
Mockito.when(userService.findByIdentifier("bob")).thenReturn(Optional.of(user));
|
||||||
|
Mockito.when(subscriptionService.getSubscribedPosts("bob")).thenReturn(java.util.List.of(post));
|
||||||
|
PostMetaDto meta = new PostMetaDto();
|
||||||
|
meta.setId(6L);
|
||||||
|
meta.setTitle("fav");
|
||||||
|
Mockito.when(userMapper.toMetaDto(post)).thenReturn(meta);
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/users/bob/subscribed-posts"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$[0].title").value("fav"));
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void listUserReplies() throws Exception {
|
void listUserReplies() throws Exception {
|
||||||
User user = new User();
|
User user = new User();
|
||||||
|
|||||||
@@ -146,7 +146,7 @@ class PostServiceTest {
|
|||||||
|
|
||||||
assertThrows(RateLimitException.class,
|
assertThrows(RateLimitException.class,
|
||||||
() -> service.createPost("alice", 1L, "t", "c", List.of(1L),
|
() -> service.createPost("alice", 1L, "t", "c", List.of(1L),
|
||||||
null, null, null, null, null, null));
|
null, null, null, null, null, null, null));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -139,6 +139,9 @@ body {
|
|||||||
margin-bottom: 0.8em;
|
margin-bottom: 0.8em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.info-content-text video {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
.info-content-text {
|
.info-content-text {
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
|
|||||||
110
frontend_nuxt/components/BaseTabs.vue
Normal file
110
frontend_nuxt/components/BaseTabs.vue
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
<template>
|
||||||
|
<div class="base-tabs-wrapper" @touchstart="onTouchStart" @touchend="onTouchEnd">
|
||||||
|
<div class="base-tabs-header">
|
||||||
|
<div class="base-tabs-items">
|
||||||
|
<div
|
||||||
|
v-for="tab in tabs"
|
||||||
|
:key="tab.name"
|
||||||
|
:class="['base-tab-item', { selected: tab.name === current }]"
|
||||||
|
@click="select(tab.name)"
|
||||||
|
>
|
||||||
|
<i v-if="tab.icon" :class="tab.icon"></i>
|
||||||
|
<span>{{ tab.label }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="base-tabs-right">
|
||||||
|
<slot name="right"></slot>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="base-tabs-content">
|
||||||
|
<slot :current="current"></slot>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: { type: String, default: undefined },
|
||||||
|
tabs: { type: Array, required: true },
|
||||||
|
swipe: { type: Boolean, default: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:modelValue'])
|
||||||
|
|
||||||
|
const current = ref(props.modelValue ?? (props.tabs[0] && props.tabs[0].name))
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.modelValue,
|
||||||
|
(val) => {
|
||||||
|
if (val !== undefined) current.value = val
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
function select(name) {
|
||||||
|
emit('update:modelValue', name)
|
||||||
|
}
|
||||||
|
|
||||||
|
let startX = 0
|
||||||
|
|
||||||
|
function onTouchStart(e) {
|
||||||
|
if (!props.swipe) return
|
||||||
|
startX = e.changedTouches[0].clientX
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTouchEnd(e) {
|
||||||
|
if (!props.swipe) return
|
||||||
|
const endX = e.changedTouches[0].clientX
|
||||||
|
const diff = endX - startX
|
||||||
|
if (Math.abs(diff) > 50) {
|
||||||
|
const index = props.tabs.findIndex((t) => t.name === current.value)
|
||||||
|
if (diff < 0 && index < props.tabs.length - 1) {
|
||||||
|
emit('update:modelValue', props.tabs[index + 1].name)
|
||||||
|
} else if (diff > 0 && index > 0) {
|
||||||
|
emit('update:modelValue', props.tabs[index - 1].name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.base-tabs-wrapper {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.base-tabs-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
border-bottom: 1px solid var(--normal-border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.base-tabs-items {
|
||||||
|
display: flex;
|
||||||
|
overflow-x: auto;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.base-tab-item {
|
||||||
|
padding: 10px 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.base-tab-item.selected {
|
||||||
|
color: var(--primary-color);
|
||||||
|
border-bottom: 2px solid var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.base-tab-item i {
|
||||||
|
margin-right: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.base-tabs-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,8 +1,21 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="floatRoute" class="message-float-window">
|
<div v-if="floatRoute" class="message-float-window" :style="{ height: floatHeight }">
|
||||||
<iframe :src="iframeSrc" frameborder="0"></iframe>
|
<iframe :src="iframeSrc" frameborder="0" ref="iframeRef" @load="injectBaseTag"></iframe>
|
||||||
|
|
||||||
<div class="float-actions">
|
<div class="float-actions">
|
||||||
<i class="fas fa-expand" @click="expand"></i>
|
<i
|
||||||
|
class="fas fa-chevron-down"
|
||||||
|
v-if="floatHeight !== MINI_HEIGHT"
|
||||||
|
title="收起至 100px"
|
||||||
|
@click="collapseToMini"
|
||||||
|
></i>
|
||||||
|
<i
|
||||||
|
class="fas fa-chevron-up"
|
||||||
|
v-if="floatHeight !== DEFAULT_HEIGHT"
|
||||||
|
title="回弹至 60vh"
|
||||||
|
@click="reboundToDefault"
|
||||||
|
></i>
|
||||||
|
<i class="fas fa-expand" title="在页面中打开" @click="expand"></i>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -10,17 +23,48 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
const floatRoute = useState('messageFloatRoute')
|
const floatRoute = useState('messageFloatRoute')
|
||||||
|
|
||||||
|
const DEFAULT_HEIGHT = '60vh'
|
||||||
|
const MINI_HEIGHT = '45px'
|
||||||
|
const floatHeight = ref(DEFAULT_HEIGHT)
|
||||||
|
|
||||||
|
const iframeRef = ref(null)
|
||||||
const iframeSrc = computed(() => {
|
const iframeSrc = computed(() => {
|
||||||
if (!floatRoute.value) return ''
|
if (!floatRoute.value) return ''
|
||||||
return floatRoute.value + (floatRoute.value.includes('?') ? '&' : '?') + 'float=1'
|
return floatRoute.value + (floatRoute.value.includes('?') ? '&' : '?') + 'float=1'
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function collapseToMini() {
|
||||||
|
floatHeight.value = MINI_HEIGHT
|
||||||
|
}
|
||||||
|
|
||||||
|
function reboundToDefault() {
|
||||||
|
floatHeight.value = DEFAULT_HEIGHT
|
||||||
|
}
|
||||||
|
|
||||||
function expand() {
|
function expand() {
|
||||||
if (!floatRoute.value) return
|
if (!floatRoute.value) return
|
||||||
const target = floatRoute.value
|
const target = floatRoute.value
|
||||||
floatRoute.value = null
|
floatRoute.value = null
|
||||||
navigateTo(target)
|
navigateTo(target)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function injectBaseTag() {
|
||||||
|
if (!iframeRef.value) return
|
||||||
|
|
||||||
|
const iframeDoc = iframeRef.value.contentDocument || iframeRef.value.contentWindow.document
|
||||||
|
if (iframeDoc && !iframeDoc.querySelector('base')) {
|
||||||
|
const base = iframeDoc.createElement('base')
|
||||||
|
base.target = '_top'
|
||||||
|
iframeDoc.head.appendChild(base)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => floatRoute.value,
|
||||||
|
(v) => {
|
||||||
|
if (v) floatHeight.value = DEFAULT_HEIGHT
|
||||||
|
},
|
||||||
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -29,7 +73,6 @@ function expand() {
|
|||||||
bottom: 0;
|
bottom: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
width: 400px;
|
width: 400px;
|
||||||
height: 60vh;
|
|
||||||
max-height: 90vh;
|
max-height: 90vh;
|
||||||
background-color: var(--background-color);
|
background-color: var(--background-color);
|
||||||
border: 1px solid var(--normal-border-color);
|
border: 1px solid var(--normal-border-color);
|
||||||
@@ -37,6 +80,8 @@ function expand() {
|
|||||||
z-index: 2000;
|
z-index: 2000;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
transition: height 0.25s ease;
|
||||||
|
/* 平滑过渡 */
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-float-window iframe {
|
.message-float-window iframe {
|
||||||
@@ -48,18 +93,18 @@ function expand() {
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
top: 4px;
|
top: 4px;
|
||||||
right: 8px;
|
right: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.float-actions i {
|
.float-actions i {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
opacity: 0.9;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
.float-actions i:hover {
|
||||||
.message-float-window {
|
opacity: 1;
|
||||||
width: 100%;
|
|
||||||
right: 0;
|
|
||||||
left: 0;
|
|
||||||
height: 100vh;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
@mouseenter="cancelHide"
|
@mouseenter="cancelHide"
|
||||||
@mouseleave="scheduleHide"
|
@mouseleave="scheduleHide"
|
||||||
>
|
>
|
||||||
<template v-if="reactions.length < 4">
|
<template v-if="Object.keys(counts).length < 4">
|
||||||
<div
|
<div
|
||||||
v-for="r in displayedReactions"
|
v-for="r in displayedReactions"
|
||||||
:key="r.type"
|
:key="r.type"
|
||||||
|
|||||||
61
frontend_nuxt/package-lock.json
generated
61
frontend_nuxt/package-lock.json
generated
@@ -15,6 +15,7 @@
|
|||||||
"markdown-it": "^14.1.0",
|
"markdown-it": "^14.1.0",
|
||||||
"nprogress": "^0.2.0",
|
"nprogress": "^0.2.0",
|
||||||
"nuxt": "latest",
|
"nuxt": "latest",
|
||||||
|
"sanitize-html": "^2.17.0",
|
||||||
"sockjs-client": "^1.6.1",
|
"sockjs-client": "^1.6.1",
|
||||||
"vditor": "^3.11.1",
|
"vditor": "^3.11.1",
|
||||||
"vue-easy-lightbox": "^1.19.0",
|
"vue-easy-lightbox": "^1.19.0",
|
||||||
@@ -6923,6 +6924,25 @@
|
|||||||
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
|
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/htmlparser2": {
|
||||||
|
"version": "8.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz",
|
||||||
|
"integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==",
|
||||||
|
"funding": [
|
||||||
|
"https://github.com/fb55/htmlparser2?sponsor=1",
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/fb55"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"domelementtype": "^2.3.0",
|
||||||
|
"domhandler": "^5.0.3",
|
||||||
|
"domutils": "^3.0.1",
|
||||||
|
"entities": "^4.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/http-errors": {
|
"node_modules/http-errors": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz",
|
||||||
@@ -7260,6 +7280,15 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/is-plain-object": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/is-reference": {
|
"node_modules/is-reference": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz",
|
||||||
@@ -8874,6 +8903,12 @@
|
|||||||
"protocols": "^2.0.0"
|
"protocols": "^2.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/parse-srcset": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/parse-url": {
|
"node_modules/parse-url": {
|
||||||
"version": "9.2.0",
|
"version": "9.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/parse-url/-/parse-url-9.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/parse-url/-/parse-url-9.2.0.tgz",
|
||||||
@@ -10018,6 +10053,32 @@
|
|||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/sanitize-html": {
|
||||||
|
"version": "2.17.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.17.0.tgz",
|
||||||
|
"integrity": "sha512-dLAADUSS8rBwhaevT12yCezvioCA+bmUTPH/u57xKPT8d++voeYE6HeluA/bPbQ15TwDBG2ii+QZIEmYx8VdxA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"deepmerge": "^4.2.2",
|
||||||
|
"escape-string-regexp": "^4.0.0",
|
||||||
|
"htmlparser2": "^8.0.0",
|
||||||
|
"is-plain-object": "^5.0.0",
|
||||||
|
"parse-srcset": "^1.0.2",
|
||||||
|
"postcss": "^8.3.11"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/sanitize-html/node_modules/escape-string-regexp": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/sax": {
|
"node_modules/sax": {
|
||||||
"version": "1.4.1",
|
"version": "1.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/sax/-/sax-1.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/sax/-/sax-1.4.1.tgz",
|
||||||
|
|||||||
@@ -9,20 +9,21 @@
|
|||||||
"generate": "nuxt generate"
|
"generate": "nuxt generate"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@stomp/stompjs": "^7.0.0",
|
||||||
"cropperjs": "^1.6.2",
|
"cropperjs": "^1.6.2",
|
||||||
"echarts": "^5.6.0",
|
"echarts": "^5.6.0",
|
||||||
|
"flatpickr": "^4.6.13",
|
||||||
"highlight.js": "^11.11.1",
|
"highlight.js": "^11.11.1",
|
||||||
"ldrs": "^1.0.0",
|
"ldrs": "^1.0.0",
|
||||||
"markdown-it": "^14.1.0",
|
"markdown-it": "^14.1.0",
|
||||||
"nuxt": "latest",
|
|
||||||
"nprogress": "^0.2.0",
|
"nprogress": "^0.2.0",
|
||||||
|
"nuxt": "latest",
|
||||||
|
"sanitize-html": "^2.17.0",
|
||||||
|
"sockjs-client": "^1.6.1",
|
||||||
"vditor": "^3.11.1",
|
"vditor": "^3.11.1",
|
||||||
"vue-easy-lightbox": "^1.19.0",
|
"vue-easy-lightbox": "^1.19.0",
|
||||||
"vue-echarts": "^7.0.3",
|
"vue-echarts": "^7.0.3",
|
||||||
"vue-toastification": "^2.0.0-rc.5",
|
|
||||||
"flatpickr": "^4.6.13",
|
|
||||||
"vue-flatpickr-component": "^12.0.0",
|
"vue-flatpickr-component": "^12.0.0",
|
||||||
"@stomp/stompjs": "^7.0.0",
|
"vue-toastification": "^2.0.0-rc.5"
|
||||||
"sockjs-client": "^1.6.1"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,33 +1,29 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="about-page">
|
<div class="about-page">
|
||||||
<div class="about-tabs">
|
<BaseTabs v-model="selectedTab" :tabs="tabs" class="about-tabs">
|
||||||
<div
|
<template #default>
|
||||||
v-for="tab in tabs"
|
<div class="about-loading" v-if="isFetching">
|
||||||
:key="tab.name"
|
<l-hatch-spinner size="100" stroke="10" speed="1" color="var(--primary-color)" />
|
||||||
:class="['about-tabs-item', { selected: selectedTab === tab.name }]"
|
</div>
|
||||||
@click="selectTab(tab.name)"
|
<div
|
||||||
>
|
v-else
|
||||||
<div class="about-tabs-item-label">{{ tab.label }}</div>
|
class="about-content"
|
||||||
</div>
|
v-html="renderMarkdown(content)"
|
||||||
</div>
|
@click="handleContentClick"
|
||||||
<div class="about-loading" v-if="isFetching">
|
></div>
|
||||||
<l-hatch-spinner size="100" stroke="10" speed="1" color="var(--primary-color)" />
|
</template>
|
||||||
</div>
|
</BaseTabs>
|
||||||
<div
|
|
||||||
v-else
|
|
||||||
class="about-content"
|
|
||||||
v-html="renderMarkdown(content)"
|
|
||||||
@click="handleContentClick"
|
|
||||||
></div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { onMounted, ref } from 'vue'
|
import { ref, watch } from 'vue'
|
||||||
import { handleMarkdownClick, renderMarkdown } from '~/utils/markdown'
|
import { handleMarkdownClick, renderMarkdown } from '~/utils/markdown'
|
||||||
|
import BaseTabs from '~/components/BaseTabs.vue'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'AboutPageView',
|
name: 'AboutPageView',
|
||||||
|
components: { BaseTabs },
|
||||||
setup() {
|
setup() {
|
||||||
const isFetching = ref(false)
|
const isFetching = ref(false)
|
||||||
const tabs = [
|
const tabs = [
|
||||||
@@ -71,21 +67,20 @@ export default {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectTab = (name) => {
|
watch(
|
||||||
selectedTab.value = name
|
selectedTab,
|
||||||
const tab = tabs.find((t) => t.name === name)
|
(name) => {
|
||||||
if (tab) loadContent(tab.file)
|
const tab = tabs.find((t) => t.name === name)
|
||||||
}
|
if (tab) loadContent(tab.file)
|
||||||
|
},
|
||||||
onMounted(() => {
|
{ immediate: true },
|
||||||
loadContent(tabs[0].file)
|
)
|
||||||
})
|
|
||||||
|
|
||||||
const handleContentClick = (e) => {
|
const handleContentClick = (e) => {
|
||||||
handleMarkdownClick(e)
|
handleMarkdownClick(e)
|
||||||
}
|
}
|
||||||
|
|
||||||
return { tabs, selectedTab, content, renderMarkdown, selectTab, isFetching, handleContentClick }
|
return { tabs, selectedTab, content, renderMarkdown, isFetching, handleContentClick }
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -100,25 +95,11 @@ export default {
|
|||||||
.about-tabs {
|
.about-tabs {
|
||||||
top: calc(var(--header-height) + 1px);
|
top: calc(var(--header-height) + 1px);
|
||||||
background-color: var(--background-color-blur);
|
background-color: var(--background-color-blur);
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
border-bottom: 1px solid var(--normal-border-color);
|
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
scrollbar-width: none;
|
scrollbar-width: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.about-tabs-item {
|
|
||||||
padding: 10px 20px;
|
|
||||||
cursor: pointer;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.about-tabs-item.selected {
|
|
||||||
color: var(--primary-color);
|
|
||||||
border-bottom: 2px solid var(--primary-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.about-content {
|
.about-content {
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
|
|||||||
@@ -125,6 +125,7 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, onMounted, onBeforeUnmount, nextTick, ref, watch } from 'vue'
|
import { computed, onMounted, onBeforeUnmount, nextTick, ref, watch } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
import ArticleCategory from '~/components/ArticleCategory.vue'
|
import ArticleCategory from '~/components/ArticleCategory.vue'
|
||||||
import ArticleTags from '~/components/ArticleTags.vue'
|
import ArticleTags from '~/components/ArticleTags.vue'
|
||||||
import CategorySelect from '~/components/CategorySelect.vue'
|
import CategorySelect from '~/components/CategorySelect.vue'
|
||||||
|
|||||||
@@ -2,15 +2,15 @@
|
|||||||
<div class="chat-container" :class="{ float: isFloatMode }">
|
<div class="chat-container" :class="{ float: isFloatMode }">
|
||||||
<div v-if="!loading" class="chat-header">
|
<div v-if="!loading" class="chat-header">
|
||||||
<div class="header-main">
|
<div class="header-main">
|
||||||
<NuxtLink to="/message-box" class="back-button">
|
<div class="back-button" @click="goBack">
|
||||||
<i class="fas fa-arrow-left"></i>
|
<i class="fas fa-arrow-left"></i>
|
||||||
</NuxtLink>
|
</div>
|
||||||
<h2 class="participant-name">
|
<h2 class="participant-name">
|
||||||
{{ isChannel ? conversationName : otherParticipant?.username }}
|
{{ isChannel ? conversationName : otherParticipant?.username }}
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="!isFloatMode" class="header-actions" @click="minimize">
|
<div v-if="!isFloatMode" class="float-control">
|
||||||
<i class="fas fa-window-minimize"></i>
|
<i class="fas fa-compress" @click="minimize" title="最小化"></i>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -415,12 +415,21 @@ function minimize() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openUser(id) {
|
function openUser(id) {
|
||||||
if (isFloatMode.value && typeof window !== 'undefined') {
|
if (isFloatMode.value) {
|
||||||
window.top.location.href = `/users/${id}`
|
// 先不处理...
|
||||||
|
// navigateTo(`/users/${id}?float=1`)
|
||||||
} else {
|
} else {
|
||||||
navigateTo(`/users/${id}`, { replace: true })
|
navigateTo(`/users/${id}`, { replace: true })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function goBack() {
|
||||||
|
if (isFloatMode.value) {
|
||||||
|
navigateTo('/message-box?float=1')
|
||||||
|
} else {
|
||||||
|
navigateTo('/message-box')
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -455,7 +464,16 @@ function openUser(id) {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-actions i {
|
.float-control {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
text-align: right;
|
||||||
|
padding: 12px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.float-control i {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -573,12 +591,6 @@ function openUser(id) {
|
|||||||
color: var(--text-color-secondary);
|
color: var(--text-color-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.messages-list {
|
|
||||||
padding: 10px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-input-area {
|
.message-input-area {
|
||||||
margin-left: 10px;
|
margin-left: 10px;
|
||||||
margin-right: 10px;
|
margin-right: 10px;
|
||||||
@@ -619,4 +631,17 @@ function openUser(id) {
|
|||||||
margin-left: 8px;
|
margin-left: 8px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-height: 200px) {
|
||||||
|
.messages-list,
|
||||||
|
.message-input-area {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.messages-list {
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,118 +1,125 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="messages-container">
|
<div class="messages-container">
|
||||||
|
<div class="page-title">
|
||||||
|
<i class="fas fa-comments"></i>
|
||||||
|
<span class="page-title-text">选择聊天</span>
|
||||||
|
</div>
|
||||||
<div v-if="!isFloatMode" class="float-control">
|
<div v-if="!isFloatMode" class="float-control">
|
||||||
<i class="fas fa-window-minimize" @click="minimize"></i>
|
<i class="fas fa-compress" @click="minimize" title="最小化"></i>
|
||||||
</div>
|
</div>
|
||||||
<div class="tabs">
|
<BaseTabs v-model="activeTab" :tabs="tabs">
|
||||||
<div :class="['tab', { active: activeTab === 'messages' }]" @click="activeTab = 'messages'">
|
<template #default>
|
||||||
站内信
|
<div v-if="activeTab === 'messages'">
|
||||||
</div>
|
<div v-if="loading" class="loading-message">
|
||||||
<div :class="['tab', { active: activeTab === 'channels' }]" @click="switchToChannels">
|
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
|
||||||
频道
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="activeTab === 'messages'">
|
|
||||||
<div v-if="loading" class="loading-message">
|
|
||||||
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else-if="error" class="error-container">
|
|
||||||
<div class="error-text">{{ error }}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="!loading" class="search-container">
|
|
||||||
<SearchPersonDropdown />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="!loading && conversations.length === 0" class="empty-container">
|
|
||||||
<BasePlaceholder v-if="conversations.length === 0" text="暂无会话" icon="fas fa-inbox" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="!loading"
|
|
||||||
v-for="convo in conversations"
|
|
||||||
:key="convo.id"
|
|
||||||
class="conversation-item"
|
|
||||||
@click="goToConversation(convo.id)"
|
|
||||||
>
|
|
||||||
<div class="conversation-avatar">
|
|
||||||
<img
|
|
||||||
:src="getOtherParticipant(convo)?.avatar || '/default-avatar.svg'"
|
|
||||||
:alt="getOtherParticipant(convo)?.username || '用户'"
|
|
||||||
class="avatar-img"
|
|
||||||
@error="handleAvatarError"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="conversation-content">
|
|
||||||
<div class="conversation-header">
|
|
||||||
<div class="participant-name">
|
|
||||||
{{ getOtherParticipant(convo)?.username || '未知用户' }}
|
|
||||||
</div>
|
|
||||||
<div class="message-time">
|
|
||||||
{{ formatTime(convo.lastMessage?.createdAt || convo.createdAt) }}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="last-message-row">
|
<div v-else-if="error" class="error-container">
|
||||||
<div class="last-message">
|
<div class="error-text">{{ error }}</div>
|
||||||
{{
|
|
||||||
convo.lastMessage ? stripMarkdownLength(convo.lastMessage.content, 100) : '暂无消息'
|
|
||||||
}}
|
|
||||||
</div>
|
|
||||||
<div v-if="convo.unreadCount > 0" class="unread-count-badge">
|
|
||||||
{{ convo.unreadCount }}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else>
|
<div v-if="!loading && !isFloatMode" class="search-container">
|
||||||
<div v-if="loadingChannels" class="loading-message">
|
<SearchPersonDropdown />
|
||||||
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
|
</div>
|
||||||
</div>
|
|
||||||
<div v-else>
|
<div v-if="!loading && conversations.length === 0" class="empty-container">
|
||||||
<div v-if="channels.length === 0" class="empty-container">
|
<BasePlaceholder
|
||||||
<BasePlaceholder text="暂无频道" icon="fas fa-inbox" />
|
v-if="conversations.length === 0"
|
||||||
</div>
|
text="暂无会话"
|
||||||
<div
|
icon="fas fa-inbox"
|
||||||
v-for="ch in channels"
|
|
||||||
:key="ch.id"
|
|
||||||
class="conversation-item"
|
|
||||||
@click="goToChannel(ch.id)"
|
|
||||||
>
|
|
||||||
<div class="conversation-avatar">
|
|
||||||
<img
|
|
||||||
:src="ch.avatar || '/default-avatar.svg'"
|
|
||||||
:alt="ch.name"
|
|
||||||
class="avatar-img"
|
|
||||||
@error="handleAvatarError"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="conversation-content">
|
|
||||||
<div class="conversation-header">
|
<div
|
||||||
<div class="participant-name">
|
v-if="!loading"
|
||||||
{{ ch.name }}
|
v-for="convo in conversations"
|
||||||
<span v-if="ch.unreadCount > 0" class="unread-dot"></span>
|
:key="convo.id"
|
||||||
</div>
|
class="conversation-item"
|
||||||
<div class="message-time">
|
@click="goToConversation(convo.id)"
|
||||||
{{ formatTime(ch.lastMessage?.createdAt || ch.createdAt) }}
|
>
|
||||||
</div>
|
<div class="conversation-avatar">
|
||||||
|
<img
|
||||||
|
:src="getOtherParticipant(convo)?.avatar || '/default-avatar.svg'"
|
||||||
|
:alt="getOtherParticipant(convo)?.username || '用户'"
|
||||||
|
class="avatar-img"
|
||||||
|
@error="handleAvatarError"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="last-message-row">
|
|
||||||
<div class="last-message">
|
<div class="conversation-content">
|
||||||
{{
|
<div class="conversation-header">
|
||||||
ch.lastMessage ? stripMarkdownLength(ch.lastMessage.content, 100) : ch.description
|
<div class="participant-name">
|
||||||
}}
|
{{ getOtherParticipant(convo)?.username || '未知用户' }}
|
||||||
|
</div>
|
||||||
|
<div class="message-time">
|
||||||
|
{{ formatTime(convo.lastMessage?.createdAt || convo.createdAt) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="last-message-row">
|
||||||
|
<div class="last-message">
|
||||||
|
{{
|
||||||
|
convo.lastMessage
|
||||||
|
? stripMarkdownLength(convo.lastMessage.content, 100)
|
||||||
|
: '暂无消息'
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
<div v-if="convo.unreadCount > 0" class="unread-count-badge">
|
||||||
|
{{ convo.unreadCount }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="member-count">成员 {{ ch.memberCount }}</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
<div v-else>
|
||||||
|
<div v-if="loadingChannels" class="loading-message">
|
||||||
|
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
<div v-if="channels.length === 0" class="empty-container">
|
||||||
|
<BasePlaceholder text="暂无频道" icon="fas fa-inbox" />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-for="ch in channels"
|
||||||
|
:key="ch.id"
|
||||||
|
class="conversation-item"
|
||||||
|
@click="goToChannel(ch.id)"
|
||||||
|
>
|
||||||
|
<div class="conversation-avatar">
|
||||||
|
<img
|
||||||
|
:src="ch.avatar || '/default-avatar.svg'"
|
||||||
|
:alt="ch.name"
|
||||||
|
class="avatar-img"
|
||||||
|
@error="handleAvatarError"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="conversation-content">
|
||||||
|
<div class="conversation-header">
|
||||||
|
<div class="participant-name">
|
||||||
|
{{ ch.name }}
|
||||||
|
<span v-if="ch.unreadCount > 0" class="unread-dot"></span>
|
||||||
|
</div>
|
||||||
|
<div class="message-time">
|
||||||
|
{{ formatTime(ch.lastMessage?.createdAt || ch.createdAt) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="last-message-row">
|
||||||
|
<div class="last-message">
|
||||||
|
{{
|
||||||
|
ch.lastMessage
|
||||||
|
? stripMarkdownLength(ch.lastMessage.content, 100)
|
||||||
|
: ch.description
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
<div class="member-count">成员 {{ ch.memberCount }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</BaseTabs>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -128,6 +135,7 @@ import TimeManager from '~/utils/time'
|
|||||||
import { stripMarkdownLength } from '~/utils/markdown'
|
import { stripMarkdownLength } from '~/utils/markdown'
|
||||||
import SearchPersonDropdown from '~/components/SearchPersonDropdown.vue'
|
import SearchPersonDropdown from '~/components/SearchPersonDropdown.vue'
|
||||||
import BasePlaceholder from '~/components/BasePlaceholder.vue'
|
import BasePlaceholder from '~/components/BasePlaceholder.vue'
|
||||||
|
import BaseTabs from '~/components/BaseTabs.vue'
|
||||||
|
|
||||||
const config = useRuntimeConfig()
|
const config = useRuntimeConfig()
|
||||||
const conversations = ref([])
|
const conversations = ref([])
|
||||||
@@ -143,12 +151,15 @@ const { fetchChannelUnread: refreshChannelUnread, setFromList: setChannelUnreadF
|
|||||||
useChannelsUnreadCount()
|
useChannelsUnreadCount()
|
||||||
let subscription = null
|
let subscription = null
|
||||||
|
|
||||||
const activeTab = ref('messages')
|
const activeTab = ref('channels')
|
||||||
const channels = ref([])
|
const channels = ref([])
|
||||||
const loadingChannels = ref(false)
|
const loadingChannels = ref(false)
|
||||||
const route = useRoute()
|
const isFloatMode = computed(() => route.query.float === '1')
|
||||||
const isFloatMode = computed(() => route.query.float !== undefined)
|
|
||||||
const floatRoute = useState('messageFloatRoute')
|
const floatRoute = useState('messageFloatRoute')
|
||||||
|
const tabs = [
|
||||||
|
{ name: 'messages', label: '站内信' },
|
||||||
|
{ name: 'channels', label: '频道' },
|
||||||
|
]
|
||||||
|
|
||||||
async function fetchConversations() {
|
async function fetchConversations() {
|
||||||
const token = getToken()
|
const token = getToken()
|
||||||
@@ -156,6 +167,7 @@ async function fetchConversations() {
|
|||||||
toast.error('请先登录')
|
toast.error('请先登录')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_BASE_URL}/api/messages/conversations`, {
|
const response = await fetch(`${API_BASE_URL}/api/messages/conversations`, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
@@ -212,13 +224,6 @@ async function fetchChannels() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function switchToChannels() {
|
|
||||||
activeTab.value = 'channels'
|
|
||||||
if (channels.value.length === 0) {
|
|
||||||
fetchChannels()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function goToChannel(id) {
|
async function goToChannel(id) {
|
||||||
const token = getToken()
|
const token = getToken()
|
||||||
if (!token) {
|
if (!token) {
|
||||||
@@ -230,19 +235,26 @@ async function goToChannel(id) {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
})
|
})
|
||||||
navigateTo(`/message-box/${id}`)
|
if (isFloatMode.value) {
|
||||||
|
navigateTo(`/message-box/${id}?float=1`)
|
||||||
|
} else {
|
||||||
|
navigateTo(`/message-box/${id}`)
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(e.message)
|
toast.error(e.message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onActivated(async () => {
|
onActivated(async () => {
|
||||||
loading.value = true
|
|
||||||
currentUser.value = await fetchCurrentUser()
|
currentUser.value = await fetchCurrentUser()
|
||||||
|
|
||||||
if (currentUser.value) {
|
if (currentUser.value) {
|
||||||
await fetchConversations()
|
if (activeTab.value === 'messages') {
|
||||||
refreshGlobalUnreadCount() // Refresh global count when entering the list
|
await fetchConversations()
|
||||||
|
} else {
|
||||||
|
await fetchChannels()
|
||||||
|
}
|
||||||
|
refreshGlobalUnreadCount()
|
||||||
refreshChannelUnread()
|
refreshChannelUnread()
|
||||||
const token = getToken()
|
const token = getToken()
|
||||||
if (token && !isConnected.value) {
|
if (token && !isConnected.value) {
|
||||||
@@ -271,6 +283,14 @@ watch(isConnected, (newValue) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
watch(activeTab, (val) => {
|
||||||
|
if (val === 'messages') {
|
||||||
|
fetchConversations()
|
||||||
|
} else {
|
||||||
|
fetchChannels()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
if (subscription) {
|
if (subscription) {
|
||||||
subscription.unsubscribe()
|
subscription.unsubscribe()
|
||||||
@@ -279,7 +299,11 @@ onUnmounted(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
function goToConversation(id) {
|
function goToConversation(id) {
|
||||||
navigateTo(`/message-box/${id}`)
|
if (isFloatMode.value) {
|
||||||
|
navigateTo(`/message-box/${id}?float=1`)
|
||||||
|
} else {
|
||||||
|
navigateTo(`/message-box/${id}`)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function minimize() {
|
function minimize() {
|
||||||
@@ -290,33 +314,25 @@ function minimize() {
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.messages-container {
|
.messages-container {
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.float-control {
|
.float-control {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
padding: 8px 12px;
|
padding: 12px 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.float-control i {
|
.float-control i {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tabs {
|
.messages-container :deep(.base-tabs-header) {
|
||||||
display: flex;
|
|
||||||
border-bottom: 1px solid var(--normal-border-color);
|
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab {
|
|
||||||
padding: 10px 20px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab.active {
|
|
||||||
border-bottom: 2px solid var(--primary-color);
|
|
||||||
color: var(--primary-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading-message {
|
.loading-message {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -334,6 +350,21 @@ function minimize() {
|
|||||||
margin-bottom: 24px;
|
margin-bottom: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
padding: 12px;
|
||||||
|
display: none;
|
||||||
|
flex-direction: row;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title-text {
|
||||||
|
margin-left: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title-text:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
.messages-title {
|
.messages-title {
|
||||||
font-size: 28px;
|
font-size: 28px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -458,7 +489,21 @@ function minimize() {
|
|||||||
margin-left: 4px;
|
margin-left: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 响应式设计 */
|
@media (max-height: 200px) {
|
||||||
|
.page-title {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.base-tabs-wrapper,
|
||||||
|
.loading-message,
|
||||||
|
.error-container,
|
||||||
|
.search-container,
|
||||||
|
.empty-container,
|
||||||
|
.conversation-item {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.conversation-item {
|
.conversation-item {
|
||||||
margin-left: 10px;
|
margin-left: 10px;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -66,6 +66,18 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="prize-point-row">
|
||||||
|
<span class="prize-row-title">参与所需积分</span>
|
||||||
|
<div class="prize-count-input">
|
||||||
|
<input
|
||||||
|
class="prize-count-input-field"
|
||||||
|
type="number"
|
||||||
|
v-model.number="pointCost"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="prize-time-row">
|
<div class="prize-time-row">
|
||||||
<span class="prize-row-title">抽奖结束时间</span>
|
<span class="prize-row-title">抽奖结束时间</span>
|
||||||
<client-only>
|
<client-only>
|
||||||
@@ -105,6 +117,7 @@ const showPrizeCropper = ref(false)
|
|||||||
const prizeName = ref('')
|
const prizeName = ref('')
|
||||||
const prizeCount = ref(1)
|
const prizeCount = ref(1)
|
||||||
const prizeDescription = ref('')
|
const prizeDescription = ref('')
|
||||||
|
const pointCost = ref(0)
|
||||||
const endTime = ref(null)
|
const endTime = ref(null)
|
||||||
const startTime = ref(null)
|
const startTime = ref(null)
|
||||||
const dateConfig = { enableTime: true, time_24hr: true, dateFormat: 'Y-m-d H:i' }
|
const dateConfig = { enableTime: true, time_24hr: true, dateFormat: 'Y-m-d H:i' }
|
||||||
@@ -133,6 +146,11 @@ watch(prizeCount, (val) => {
|
|||||||
if (!val || val < 1) prizeCount.value = 1
|
if (!val || val < 1) prizeCount.value = 1
|
||||||
})
|
})
|
||||||
|
|
||||||
|
watch(pointCost, (val) => {
|
||||||
|
if (val === undefined || val === null || val < 0) pointCost.value = 0
|
||||||
|
if (val > 100) pointCost.value = 100
|
||||||
|
})
|
||||||
|
|
||||||
const loadDraft = async () => {
|
const loadDraft = async () => {
|
||||||
const token = getToken()
|
const token = getToken()
|
||||||
if (!token) return
|
if (!token) return
|
||||||
@@ -168,6 +186,7 @@ const clearPost = async () => {
|
|||||||
showPrizeCropper.value = false
|
showPrizeCropper.value = false
|
||||||
prizeDescription.value = ''
|
prizeDescription.value = ''
|
||||||
prizeCount.value = 1
|
prizeCount.value = 1
|
||||||
|
pointCost.value = 0
|
||||||
endTime.value = null
|
endTime.value = null
|
||||||
startTime.value = null
|
startTime.value = null
|
||||||
|
|
||||||
@@ -315,6 +334,10 @@ const submitPost = async () => {
|
|||||||
toast.error('请选择抽奖结束时间')
|
toast.error('请选择抽奖结束时间')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (pointCost.value < 0 || pointCost.value > 100) {
|
||||||
|
toast.error('参与积分需在0到100之间')
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const token = getToken()
|
const token = getToken()
|
||||||
@@ -354,6 +377,7 @@ const submitPost = async () => {
|
|||||||
prizeDescription: postType.value === 'LOTTERY' ? prizeDescription.value : undefined,
|
prizeDescription: postType.value === 'LOTTERY' ? prizeDescription.value : undefined,
|
||||||
startTime:
|
startTime:
|
||||||
postType.value === 'LOTTERY' ? new Date(startTime.value).toISOString() : undefined,
|
postType.value === 'LOTTERY' ? new Date(startTime.value).toISOString() : undefined,
|
||||||
|
pointCost: postType.value === 'LOTTERY' ? pointCost.value : undefined,
|
||||||
// 将时间转换为 UTC+8.5 时区 todo: 需要优化
|
// 将时间转换为 UTC+8.5 时区 todo: 需要优化
|
||||||
endTime:
|
endTime:
|
||||||
postType.value === 'LOTTERY'
|
postType.value === 'LOTTERY'
|
||||||
@@ -498,6 +522,8 @@ const submitPost = async () => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 20px;
|
gap: 20px;
|
||||||
|
|
||||||
|
margin-bottom: 200px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.prize-row-title {
|
.prize-row-title {
|
||||||
|
|||||||
@@ -1,159 +1,172 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="point-mall-page">
|
<div class="point-mall-page">
|
||||||
<div class="point-tabs">
|
<BaseTabs v-model="selectedTab" :tabs="tabs">
|
||||||
<div
|
<template #default>
|
||||||
:class="['point-tab-item', { selected: selectedTab === 'mall' }]"
|
<template v-if="selectedTab === 'mall'">
|
||||||
@click="selectedTab = 'mall'"
|
<div class="point-mall-page-content">
|
||||||
>
|
<section class="rules">
|
||||||
积分兑换
|
<div class="section-title">🎉 积分规则</div>
|
||||||
</div>
|
<div class="section-content">
|
||||||
<div
|
<div class="section-item" v-for="(rule, idx) in pointRules" :key="idx">
|
||||||
:class="['point-tab-item', { selected: selectedTab === 'history' }]"
|
{{ rule }}
|
||||||
@click="selectedTab = 'history'"
|
</div>
|
||||||
>
|
</div>
|
||||||
积分历史
|
</section>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<template v-if="selectedTab === 'mall'">
|
<div class="loading-points-container" v-if="isLoading">
|
||||||
<div class="point-mall-page-content">
|
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
|
||||||
<section class="rules">
|
|
||||||
<div class="section-title">🎉 积分规则</div>
|
|
||||||
<div class="section-content">
|
|
||||||
<div class="section-item" v-for="(rule, idx) in pointRules" :key="idx">{{ rule }}</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<div class="loading-points-container" v-if="isLoading">
|
|
||||||
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="point-info">
|
|
||||||
<p v-if="authState.loggedIn && point !== null">
|
|
||||||
<span><i class="fas fa-coins coin-icon"></i></span>我的积分:<span
|
|
||||||
class="point-value"
|
|
||||||
>{{ point }}</span
|
|
||||||
>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<section class="goods">
|
|
||||||
<div class="goods-item" v-for="(good, idx) in goods" :key="idx">
|
|
||||||
<img class="goods-item-image" :src="good.image" alt="good.name" />
|
|
||||||
<div class="goods-item-name">{{ good.name }}</div>
|
|
||||||
<div class="goods-item-cost">
|
|
||||||
<i class="fas fa-coins"></i>
|
|
||||||
{{ good.cost }} 积分
|
|
||||||
</div>
|
</div>
|
||||||
<div
|
|
||||||
class="goods-item-button"
|
|
||||||
:class="{ disabled: !authState.loggedIn || point === null || point < good.cost }"
|
|
||||||
@click="openRedeem(good)"
|
|
||||||
>
|
|
||||||
兑换
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
<RedeemPopup
|
|
||||||
:visible="dialogVisible"
|
|
||||||
v-model="contact"
|
|
||||||
:loading="loading"
|
|
||||||
@close="closeRedeem"
|
|
||||||
@submit="submitRedeem"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template v-else>
|
<div class="point-info">
|
||||||
<div class="loading-points-container" v-if="historyLoading">
|
<p v-if="authState.loggedIn && point !== null">
|
||||||
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
|
<span><i class="fas fa-coins coin-icon"></i></span>我的积分:<span
|
||||||
</div>
|
class="point-value"
|
||||||
<BasePlaceholder v-else-if="histories.length === 0" text="暂无积分记录" icon="fas fa-inbox" />
|
>{{ point }}</span
|
||||||
<div class="timeline-container" v-else>
|
|
||||||
<BaseTimeline :items="histories">
|
|
||||||
<template #item="{ item }">
|
|
||||||
<div class="history-content">
|
|
||||||
<template v-if="item.type === 'POST'">
|
|
||||||
发送帖子
|
|
||||||
<NuxtLink :to="`/posts/${item.postId}`" class="timeline-link">{{
|
|
||||||
item.postTitle
|
|
||||||
}}</NuxtLink>
|
|
||||||
,获得{{ item.amount }}积分
|
|
||||||
</template>
|
|
||||||
<template v-else-if="item.type === 'COMMENT'">
|
|
||||||
在文章
|
|
||||||
<NuxtLink :to="`/posts/${item.postId}`" class="timeline-link">{{
|
|
||||||
item.postTitle
|
|
||||||
}}</NuxtLink>
|
|
||||||
中
|
|
||||||
<template v-if="!item.fromUserId">
|
|
||||||
发送评论
|
|
||||||
<NuxtLink
|
|
||||||
:to="`/posts/${item.postId}#comment-${item.commentId}`"
|
|
||||||
class="timeline-link"
|
|
||||||
>{{ stripMarkdownLength(item.commentContent, 100) }}</NuxtLink
|
|
||||||
>
|
|
||||||
,获得{{ item.amount }}积分
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
被评论
|
|
||||||
<NuxtLink
|
|
||||||
:to="`/posts/${item.postId}#comment-${item.commentId}`"
|
|
||||||
class="timeline-link"
|
|
||||||
>{{ stripMarkdownLength(item.commentContent, 100) }}</NuxtLink
|
|
||||||
>
|
|
||||||
,获得{{ item.amount }}积分
|
|
||||||
</template>
|
|
||||||
</template>
|
|
||||||
<template v-else-if="item.type === 'POST_LIKED' && item.fromUserId">
|
|
||||||
帖子
|
|
||||||
<NuxtLink :to="`/posts/${item.postId}`" class="timeline-link">{{
|
|
||||||
item.postTitle
|
|
||||||
}}</NuxtLink>
|
|
||||||
被
|
|
||||||
<NuxtLink :to="`/users/${item.fromUserId}`" class="timeline-link">{{
|
|
||||||
item.fromUserName
|
|
||||||
}}</NuxtLink>
|
|
||||||
按赞,获得{{ item.amount }}积分
|
|
||||||
</template>
|
|
||||||
<template v-else-if="item.type === 'COMMENT_LIKED' && item.fromUserId">
|
|
||||||
评论
|
|
||||||
<NuxtLink
|
|
||||||
:to="`/posts/${item.postId}#comment-${item.commentId}`"
|
|
||||||
class="timeline-link"
|
|
||||||
>{{ stripMarkdownLength(item.commentContent, 100) }}</NuxtLink
|
|
||||||
>
|
>
|
||||||
被
|
</p>
|
||||||
<NuxtLink :to="`/users/${item.fromUserId}`" class="timeline-link">{{
|
|
||||||
item.fromUserName
|
|
||||||
}}</NuxtLink>
|
|
||||||
按赞,获得{{ item.amount }}积分
|
|
||||||
</template>
|
|
||||||
<template v-else-if="item.type === 'INVITE' && item.fromUserId">
|
|
||||||
邀请了好友
|
|
||||||
<NuxtLink :to="`/users/${item.fromUserId}`" class="timeline-link">{{
|
|
||||||
item.fromUserName
|
|
||||||
}}</NuxtLink>
|
|
||||||
加入社区 🎉,获得 {{ item.amount }} 积分
|
|
||||||
</template>
|
|
||||||
<template v-else-if="item.type === 'FEATURE'">
|
|
||||||
文章
|
|
||||||
<NuxtLink :to="`/posts/${item.postId}`" class="timeline-link">{{
|
|
||||||
item.postTitle
|
|
||||||
}}</NuxtLink>
|
|
||||||
被收录为精选,获得 {{ item.amount }} 积分
|
|
||||||
</template>
|
|
||||||
<template v-else-if="item.type === 'REDEEM'">
|
|
||||||
兑换商品,消耗 {{ -item.amount }} 积分
|
|
||||||
</template>
|
|
||||||
<template v-else-if="item.type === 'SYSTEM_ONLINE'"> 积分历史系统上线 </template>
|
|
||||||
<i class="fas fa-coins"></i> 你目前的积分是 {{ item.balance }}
|
|
||||||
</div>
|
</div>
|
||||||
<div class="history-time">{{ TimeManager.format(item.createdAt) }}</div>
|
|
||||||
</template>
|
<section class="goods">
|
||||||
</BaseTimeline>
|
<div class="goods-item" v-for="(good, idx) in goods" :key="idx">
|
||||||
</div>
|
<img class="goods-item-image" :src="good.image" alt="good.name" />
|
||||||
</template>
|
<div class="goods-item-name">{{ good.name }}</div>
|
||||||
|
<div class="goods-item-cost">
|
||||||
|
<i class="fas fa-coins"></i>
|
||||||
|
{{ good.cost }} 积分
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="goods-item-button"
|
||||||
|
:class="{ disabled: !authState.loggedIn || point === null || point < good.cost }"
|
||||||
|
@click="openRedeem(good)"
|
||||||
|
>
|
||||||
|
兑换
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<RedeemPopup
|
||||||
|
:visible="dialogVisible"
|
||||||
|
v-model="contact"
|
||||||
|
:loading="loading"
|
||||||
|
@close="closeRedeem"
|
||||||
|
@submit="submitRedeem"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<div class="loading-points-container" v-if="historyLoading">
|
||||||
|
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
|
||||||
|
</div>
|
||||||
|
<BasePlaceholder
|
||||||
|
v-else-if="histories.length === 0"
|
||||||
|
text="暂无积分记录"
|
||||||
|
icon="fas fa-inbox"
|
||||||
|
/>
|
||||||
|
<div class="timeline-container" v-else>
|
||||||
|
<BaseTimeline :items="histories">
|
||||||
|
<template #item="{ item }">
|
||||||
|
<div class="history-content">
|
||||||
|
<template v-if="item.type === 'POST'">
|
||||||
|
发送帖子
|
||||||
|
<NuxtLink :to="`/posts/${item.postId}`" class="timeline-link">{{
|
||||||
|
item.postTitle
|
||||||
|
}}</NuxtLink>
|
||||||
|
,获得{{ item.amount }}积分
|
||||||
|
</template>
|
||||||
|
<template v-else-if="item.type === 'COMMENT'">
|
||||||
|
在文章
|
||||||
|
<NuxtLink :to="`/posts/${item.postId}`" class="timeline-link">{{
|
||||||
|
item.postTitle
|
||||||
|
}}</NuxtLink>
|
||||||
|
中
|
||||||
|
<template v-if="!item.fromUserId">
|
||||||
|
发送评论
|
||||||
|
<NuxtLink
|
||||||
|
:to="`/posts/${item.postId}#comment-${item.commentId}`"
|
||||||
|
class="timeline-link"
|
||||||
|
>{{ stripMarkdownLength(item.commentContent, 100) }}</NuxtLink
|
||||||
|
>
|
||||||
|
,获得{{ item.amount }}积分
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
被评论
|
||||||
|
<NuxtLink
|
||||||
|
:to="`/posts/${item.postId}#comment-${item.commentId}`"
|
||||||
|
class="timeline-link"
|
||||||
|
>{{ stripMarkdownLength(item.commentContent, 100) }}</NuxtLink
|
||||||
|
>
|
||||||
|
,获得{{ item.amount }}积分
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="item.type === 'POST_LIKED' && item.fromUserId">
|
||||||
|
帖子
|
||||||
|
<NuxtLink :to="`/posts/${item.postId}`" class="timeline-link">{{
|
||||||
|
item.postTitle
|
||||||
|
}}</NuxtLink>
|
||||||
|
被
|
||||||
|
<NuxtLink :to="`/users/${item.fromUserId}`" class="timeline-link">{{
|
||||||
|
item.fromUserName
|
||||||
|
}}</NuxtLink>
|
||||||
|
按赞,获得{{ item.amount }}积分
|
||||||
|
</template>
|
||||||
|
<template v-else-if="item.type === 'COMMENT_LIKED' && item.fromUserId">
|
||||||
|
评论
|
||||||
|
<NuxtLink
|
||||||
|
:to="`/posts/${item.postId}#comment-${item.commentId}`"
|
||||||
|
class="timeline-link"
|
||||||
|
>{{ stripMarkdownLength(item.commentContent, 100) }}</NuxtLink
|
||||||
|
>
|
||||||
|
被
|
||||||
|
<NuxtLink :to="`/users/${item.fromUserId}`" class="timeline-link">{{
|
||||||
|
item.fromUserName
|
||||||
|
}}</NuxtLink>
|
||||||
|
按赞,获得{{ item.amount }}积分
|
||||||
|
</template>
|
||||||
|
<template v-else-if="item.type === 'INVITE' && item.fromUserId">
|
||||||
|
邀请了好友
|
||||||
|
<NuxtLink :to="`/users/${item.fromUserId}`" class="timeline-link">{{
|
||||||
|
item.fromUserName
|
||||||
|
}}</NuxtLink>
|
||||||
|
加入社区 🎉,获得 {{ item.amount }} 积分
|
||||||
|
</template>
|
||||||
|
<template v-else-if="item.type === 'FEATURE'">
|
||||||
|
文章
|
||||||
|
<NuxtLink :to="`/posts/${item.postId}`" class="timeline-link">{{
|
||||||
|
item.postTitle
|
||||||
|
}}</NuxtLink>
|
||||||
|
被收录为精选,获得 {{ item.amount }} 积分
|
||||||
|
</template>
|
||||||
|
<template v-else-if="item.type === 'REDEEM'">
|
||||||
|
兑换商品,消耗 {{ -item.amount }} 积分
|
||||||
|
</template>
|
||||||
|
<template v-else-if="item.type === 'LOTTERY_JOIN'">
|
||||||
|
参与抽奖帖
|
||||||
|
<NuxtLink :to="`/posts/${item.postId}`" class="timeline-link">{{
|
||||||
|
item.postTitle
|
||||||
|
}}</NuxtLink>
|
||||||
|
,消耗 {{ -item.amount }} 积分
|
||||||
|
</template>
|
||||||
|
<template v-else-if="item.type === 'LOTTERY_REWARD'">
|
||||||
|
你的抽奖帖
|
||||||
|
<NuxtLink :to="`/posts/${item.postId}`" class="timeline-link">{{
|
||||||
|
item.postTitle
|
||||||
|
}}</NuxtLink>
|
||||||
|
被
|
||||||
|
<NuxtLink :to="`/users/${item.fromUserId}`" class="timeline-link">{{
|
||||||
|
item.fromUserName
|
||||||
|
}}</NuxtLink>
|
||||||
|
参与,获得 {{ item.amount }} 积分
|
||||||
|
</template>
|
||||||
|
<template v-else-if="item.type === 'SYSTEM_ONLINE'"> 积分历史系统上线 </template>
|
||||||
|
<i class="fas fa-coins"></i> 你目前的积分是 {{ item.balance }}
|
||||||
|
</div>
|
||||||
|
<div class="history-time">{{ TimeManager.format(item.createdAt) }}</div>
|
||||||
|
</template>
|
||||||
|
</BaseTimeline>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</BaseTabs>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -164,6 +177,7 @@ import { toast } from '~/main'
|
|||||||
import RedeemPopup from '~/components/RedeemPopup.vue'
|
import RedeemPopup from '~/components/RedeemPopup.vue'
|
||||||
import BaseTimeline from '~/components/BaseTimeline.vue'
|
import BaseTimeline from '~/components/BaseTimeline.vue'
|
||||||
import BasePlaceholder from '~/components/BasePlaceholder.vue'
|
import BasePlaceholder from '~/components/BasePlaceholder.vue'
|
||||||
|
import BaseTabs from '~/components/BaseTabs.vue'
|
||||||
import { stripMarkdownLength } from '~/utils/markdown'
|
import { stripMarkdownLength } from '~/utils/markdown'
|
||||||
import TimeManager from '~/utils/time'
|
import TimeManager from '~/utils/time'
|
||||||
|
|
||||||
@@ -171,6 +185,10 @@ const config = useRuntimeConfig()
|
|||||||
const API_BASE_URL = config.public.apiBaseUrl
|
const API_BASE_URL = config.public.apiBaseUrl
|
||||||
|
|
||||||
const selectedTab = ref('mall')
|
const selectedTab = ref('mall')
|
||||||
|
const tabs = [
|
||||||
|
{ name: 'mall', label: '积分兑换' },
|
||||||
|
{ name: 'history', label: '积分历史' },
|
||||||
|
]
|
||||||
const point = ref(null)
|
const point = ref(null)
|
||||||
const isLoading = ref(false)
|
const isLoading = ref(false)
|
||||||
const histories = ref([])
|
const histories = ref([])
|
||||||
@@ -201,6 +219,8 @@ const iconMap = {
|
|||||||
SYSTEM_ONLINE: 'fas fa-clock',
|
SYSTEM_ONLINE: 'fas fa-clock',
|
||||||
REDEEM: 'fas fa-gift',
|
REDEEM: 'fas fa-gift',
|
||||||
FEATURE: 'fas fa-star',
|
FEATURE: 'fas fa-star',
|
||||||
|
LOTTERY_JOIN: 'fas fa-ticket-alt',
|
||||||
|
LOTTERY_REWARD: 'fas fa-ticket-alt',
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
@@ -295,21 +315,6 @@ const submitRedeem = async () => {
|
|||||||
padding: 0 20px;
|
padding: 0 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.point-tabs {
|
|
||||||
display: flex;
|
|
||||||
border-bottom: 1px solid var(--normal-border-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.point-tab-item {
|
|
||||||
padding: 10px 15px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.point-tab-item.selected {
|
|
||||||
border-bottom: 2px solid var(--primary-color);
|
|
||||||
color: var(--primary-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.timeline-container {
|
.timeline-container {
|
||||||
padding: 10px 20px;
|
padding: 10px 20px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,7 +94,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="lottery" class="prize-container">
|
<div v-if="lottery" class="post-prize-container">
|
||||||
<div class="prize-content">
|
<div class="prize-content">
|
||||||
<div class="prize-info">
|
<div class="prize-info">
|
||||||
<div class="prize-info-left">
|
<div class="prize-info-left">
|
||||||
@@ -119,7 +119,9 @@
|
|||||||
class="join-prize-button"
|
class="join-prize-button"
|
||||||
@click="joinLottery"
|
@click="joinLottery"
|
||||||
>
|
>
|
||||||
<div class="join-prize-button-text">参与抽奖</div>
|
<div class="join-prize-button-text">
|
||||||
|
参与抽奖 <i class="fas fa-coins"></i> {{ lottery.pointCost }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="hasJoined" class="join-prize-button-disabled">
|
<div v-else-if="hasJoined" class="join-prize-button-disabled">
|
||||||
<div class="join-prize-button-text">已参与</div>
|
<div class="join-prize-button-text">已参与</div>
|
||||||
@@ -134,7 +136,9 @@
|
|||||||
class="join-prize-button"
|
class="join-prize-button"
|
||||||
@click="joinLottery"
|
@click="joinLottery"
|
||||||
>
|
>
|
||||||
<div class="join-prize-button-text">参与抽奖</div>
|
<div class="join-prize-button-text">
|
||||||
|
参与抽奖 <i class="fas fa-coins"></i> {{ lottery.pointCost }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="hasJoined" class="join-prize-button-disabled">
|
<div v-else-if="hasJoined" class="join-prize-button-disabled">
|
||||||
<div class="join-prize-button-text">已参与</div>
|
<div class="join-prize-button-text">已参与</div>
|
||||||
@@ -810,11 +814,12 @@ const joinLottery = async () => {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
})
|
})
|
||||||
|
const data = await res.json().catch(() => ({}))
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
toast.success('已参与抽奖')
|
toast.success('已参与抽奖')
|
||||||
await refreshPost()
|
await refreshPost()
|
||||||
} else {
|
} else {
|
||||||
toast.error('操作失败')
|
toast.error(data.error || '操作失败')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1262,7 +1267,7 @@ onMounted(async () => {
|
|||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.prize-container {
|
.post-prize-container {
|
||||||
margin-top: 20px;
|
margin-top: 20px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -72,74 +72,172 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="profile-tabs">
|
<BaseTabs v-model="selectedTab" :tabs="tabs" class="profile-tabs">
|
||||||
<div
|
<template #default>
|
||||||
:class="['profile-tabs-item', { selected: selectedTab === 'summary' }]"
|
<div v-if="tabLoading" class="tab-loading">
|
||||||
@click="selectedTab = 'summary'"
|
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)" />
|
||||||
>
|
</div>
|
||||||
<i class="fas fa-chart-line"></i>
|
<template v-else>
|
||||||
<div class="profile-tabs-item-label">总结</div>
|
<div v-if="selectedTab === 'summary'" class="profile-summary">
|
||||||
</div>
|
<div class="total-summary">
|
||||||
<div
|
<div class="summary-title">统计信息</div>
|
||||||
:class="['profile-tabs-item', { selected: selectedTab === 'timeline' }]"
|
<div class="total-summary-content">
|
||||||
@click="selectedTab = 'timeline'"
|
<div class="total-summary-item">
|
||||||
>
|
<div class="total-summary-item-label">访问天数</div>
|
||||||
<i class="fas fa-clock"></i>
|
<div class="total-summary-item-value">{{ user.visitedDays }}</div>
|
||||||
<div class="profile-tabs-item-label">时间线</div>
|
</div>
|
||||||
</div>
|
<div class="total-summary-item">
|
||||||
<div
|
<div class="total-summary-item-label">已读帖子</div>
|
||||||
:class="['profile-tabs-item', { selected: selectedTab === 'following' }]"
|
<div class="total-summary-item-value">{{ user.readPosts }}</div>
|
||||||
@click="selectedTab = 'following'"
|
</div>
|
||||||
>
|
<div class="total-summary-item">
|
||||||
<i class="fas fa-user-plus"></i>
|
<div class="total-summary-item-label">已送出的💗</div>
|
||||||
<div class="profile-tabs-item-label">关注</div>
|
<div class="total-summary-item-value">{{ user.likesSent }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div class="total-summary-item">
|
||||||
:class="['profile-tabs-item', { selected: selectedTab === 'achievements' }]"
|
<div class="total-summary-item-label">已收到的💗</div>
|
||||||
@click="selectedTab = 'achievements'"
|
<div class="total-summary-item-value">{{ user.likesReceived }}</div>
|
||||||
>
|
</div>
|
||||||
<i class="fas fa-medal"></i>
|
</div>
|
||||||
<div class="profile-tabs-item-label">勋章</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="tabLoading" class="tab-loading">
|
|
||||||
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)" />
|
|
||||||
</div>
|
|
||||||
<template v-else>
|
|
||||||
<div v-if="selectedTab === 'summary'" 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">{{ user.visitedDays }}</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="total-summary-item">
|
<div class="summary-divider">
|
||||||
<div class="total-summary-item-label">已读帖子</div>
|
<div class="hot-reply">
|
||||||
<div class="total-summary-item-value">{{ user.readPosts }}</div>
|
<div class="summary-title">热门回复</div>
|
||||||
</div>
|
<div class="summary-content" v-if="hotReplies.length > 0">
|
||||||
<div class="total-summary-item">
|
<BaseTimeline :items="hotReplies">
|
||||||
<div class="total-summary-item-label">已送出的💗</div>
|
<template #item="{ item }">
|
||||||
<div class="total-summary-item-value">{{ user.likesSent }}</div>
|
在
|
||||||
</div>
|
<NuxtLink :to="`/posts/${item.comment.post.id}`" class="timeline-link">
|
||||||
<div class="total-summary-item">
|
{{ item.comment.post.title }}
|
||||||
<div class="total-summary-item-label">已收到的💗</div>
|
</NuxtLink>
|
||||||
<div class="total-summary-item-value">{{ user.likesReceived }}</div>
|
<template v-if="item.comment.parentComment">
|
||||||
|
下对
|
||||||
|
<NuxtLink
|
||||||
|
:to="`/posts/${item.comment.post.id}#comment-${item.comment.parentComment.id}`"
|
||||||
|
class="timeline-link"
|
||||||
|
>
|
||||||
|
{{ stripMarkdownLength(item.comment.parentComment.content, 200) }}
|
||||||
|
</NuxtLink>
|
||||||
|
回复了
|
||||||
|
</template>
|
||||||
|
<template v-else> 下评论了 </template>
|
||||||
|
<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.comment.createdAt) }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</BaseTimeline>
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
<div class="summary-empty">暂无热门回复</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="hot-topic">
|
||||||
|
<div class="summary-title">热门话题</div>
|
||||||
|
<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>
|
||||||
|
</template>
|
||||||
|
</BaseTimeline>
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div class="summary-divider">
|
<div v-else-if="selectedTab === 'timeline'" class="profile-timeline">
|
||||||
<div class="hot-reply">
|
<div class="timeline-tabs">
|
||||||
<div class="summary-title">热门回复</div>
|
<div
|
||||||
<div class="summary-content" v-if="hotReplies.length > 0">
|
:class="['timeline-tab-item', { selected: timelineFilter === 'all' }]"
|
||||||
<BaseTimeline :items="hotReplies">
|
@click="timelineFilter = 'all'"
|
||||||
|
>
|
||||||
|
全部
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
:class="['timeline-tab-item', { selected: timelineFilter === 'articles' }]"
|
||||||
|
@click="timelineFilter = 'articles'"
|
||||||
|
>
|
||||||
|
文章
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
:class="['timeline-tab-item', { selected: timelineFilter === 'comments' }]"
|
||||||
|
@click="timelineFilter = 'comments'"
|
||||||
|
>
|
||||||
|
评论和回复
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<BasePlaceholder
|
||||||
|
v-if="filteredTimelineItems.length === 0"
|
||||||
|
text="暂无时间线"
|
||||||
|
icon="fas fa-inbox"
|
||||||
|
/>
|
||||||
|
<div class="timeline-list">
|
||||||
|
<BaseTimeline :items="filteredTimelineItems">
|
||||||
<template #item="{ item }">
|
<template #item="{ item }">
|
||||||
在
|
<template v-if="item.type === 'post'">
|
||||||
<NuxtLink :to="`/posts/${item.comment.post.id}`" class="timeline-link">
|
发布了文章
|
||||||
{{ item.comment.post.title }}
|
<NuxtLink :to="`/posts/${item.post.id}`" class="timeline-link">
|
||||||
</NuxtLink>
|
{{ item.post.title }}
|
||||||
<template v-if="item.comment.parentComment">
|
</NuxtLink>
|
||||||
|
<div class="timeline-date">{{ formatDate(item.createdAt) }}</div>
|
||||||
|
</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>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="item.type === 'reply'">
|
||||||
|
在
|
||||||
|
<NuxtLink :to="`/posts/${item.comment.post.id}`" class="timeline-link">
|
||||||
|
{{ item.comment.post.title }}
|
||||||
|
</NuxtLink>
|
||||||
下对
|
下对
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
:to="`/posts/${item.comment.post.id}#comment-${item.comment.parentComment.id}`"
|
:to="`/posts/${item.comment.post.id}#comment-${item.comment.parentComment.id}`"
|
||||||
@@ -148,28 +246,53 @@
|
|||||||
{{ stripMarkdownLength(item.comment.parentComment.content, 200) }}
|
{{ stripMarkdownLength(item.comment.parentComment.content, 200) }}
|
||||||
</NuxtLink>
|
</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>
|
||||||
|
</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 v-else> 下评论了 </template>
|
|
||||||
<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.comment.createdAt) }}
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
</BaseTimeline>
|
</BaseTimeline>
|
||||||
</div>
|
</div>
|
||||||
<div v-else>
|
</div>
|
||||||
<div class="summary-empty">暂无热门回复</div>
|
|
||||||
|
<div v-else-if="selectedTab === 'following'" class="follow-container">
|
||||||
|
<div class="follow-tabs">
|
||||||
|
<div
|
||||||
|
:class="['follow-tab-item', { selected: followTab === 'followers' }]"
|
||||||
|
@click="followTab = 'followers'"
|
||||||
|
>
|
||||||
|
关注者
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
:class="['follow-tab-item', { selected: followTab === 'following' }]"
|
||||||
|
@click="followTab = 'following'"
|
||||||
|
>
|
||||||
|
正在关注
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="follow-list">
|
||||||
|
<UserList v-if="followTab === 'followers'" :users="followers" />
|
||||||
|
<UserList v-else :users="followings" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="hot-topic">
|
|
||||||
<div class="summary-title">热门话题</div>
|
<div v-else-if="selectedTab === 'favorites'" class="favorites-container">
|
||||||
<div class="summary-content" v-if="hotPosts.length > 0">
|
<div v-if="favoritePosts.length > 0">
|
||||||
<BaseTimeline :items="hotPosts">
|
<BaseTimeline :items="favoritePosts">
|
||||||
<template #item="{ item }">
|
<template #item="{ item }">
|
||||||
<NuxtLink :to="`/posts/${item.post.id}`" class="timeline-link">
|
<NuxtLink :to="`/posts/${item.post.id}`" class="timeline-link">
|
||||||
{{ item.post.title }}
|
{{ item.post.title }}
|
||||||
@@ -177,151 +300,21 @@
|
|||||||
<div class="timeline-snippet">
|
<div class="timeline-snippet">
|
||||||
{{ stripMarkdown(item.post.snippet) }}
|
{{ stripMarkdown(item.post.snippet) }}
|
||||||
</div>
|
</div>
|
||||||
<div class="timeline-date">
|
<div class="timeline-date">{{ formatDate(item.post.createdAt) }}</div>
|
||||||
{{ formatDate(item.post.createdAt) }}
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
</BaseTimeline>
|
</BaseTimeline>
|
||||||
</div>
|
</div>
|
||||||
<div v-else>
|
<div v-else>
|
||||||
<div class="summary-empty">暂无热门话题</div>
|
<BasePlaceholder text="暂无收藏文章" icon="fas fa-inbox" />
|
||||||
</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 v-else-if="selectedTab === 'timeline'" class="profile-timeline">
|
<div v-else-if="selectedTab === 'achievements'" class="achievements-container">
|
||||||
<div class="timeline-tabs">
|
<AchievementList :medals="medals" :can-select="isMine" />
|
||||||
<div
|
|
||||||
:class="['timeline-tab-item', { selected: timelineFilter === 'all' }]"
|
|
||||||
@click="timelineFilter = 'all'"
|
|
||||||
>
|
|
||||||
全部
|
|
||||||
</div>
|
</div>
|
||||||
<div
|
</template>
|
||||||
:class="['timeline-tab-item', { selected: timelineFilter === 'articles' }]"
|
</template>
|
||||||
@click="timelineFilter = 'articles'"
|
</BaseTabs>
|
||||||
>
|
|
||||||
文章
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
:class="['timeline-tab-item', { selected: timelineFilter === 'comments' }]"
|
|
||||||
@click="timelineFilter = 'comments'"
|
|
||||||
>
|
|
||||||
评论和回复
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<BasePlaceholder
|
|
||||||
v-if="filteredTimelineItems.length === 0"
|
|
||||||
text="暂无时间线"
|
|
||||||
icon="fas fa-inbox"
|
|
||||||
/>
|
|
||||||
<div class="timeline-list">
|
|
||||||
<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>
|
|
||||||
</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>
|
|
||||||
</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>
|
|
||||||
</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>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else-if="selectedTab === 'following'" class="follow-container">
|
|
||||||
<div class="follow-tabs">
|
|
||||||
<div
|
|
||||||
:class="['follow-tab-item', { selected: followTab === 'followers' }]"
|
|
||||||
@click="followTab = 'followers'"
|
|
||||||
>
|
|
||||||
关注者
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
:class="['follow-tab-item', { selected: followTab === 'following' }]"
|
|
||||||
@click="followTab = 'following'"
|
|
||||||
>
|
|
||||||
正在关注
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="follow-list">
|
|
||||||
<UserList v-if="followTab === 'followers'" :users="followers" />
|
|
||||||
<UserList v-else :users="followings" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else-if="selectedTab === 'achievements'" class="achievements-container">
|
|
||||||
<AchievementList :medals="medals" :can-select="isMine" />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -334,6 +327,7 @@ import BasePlaceholder from '~/components/BasePlaceholder.vue'
|
|||||||
import BaseTimeline from '~/components/BaseTimeline.vue'
|
import BaseTimeline from '~/components/BaseTimeline.vue'
|
||||||
import LevelProgress from '~/components/LevelProgress.vue'
|
import LevelProgress from '~/components/LevelProgress.vue'
|
||||||
import UserList from '~/components/UserList.vue'
|
import UserList from '~/components/UserList.vue'
|
||||||
|
import BaseTabs from '~/components/BaseTabs.vue'
|
||||||
import { toast } from '~/main'
|
import { toast } from '~/main'
|
||||||
import { authState, getToken } from '~/utils/auth'
|
import { authState, getToken } from '~/utils/auth'
|
||||||
import { prevLevelExp } from '~/utils/level'
|
import { prevLevelExp } from '~/utils/level'
|
||||||
@@ -352,6 +346,7 @@ const user = ref({})
|
|||||||
const hotPosts = ref([])
|
const hotPosts = ref([])
|
||||||
const hotReplies = ref([])
|
const hotReplies = ref([])
|
||||||
const hotTags = ref([])
|
const hotTags = ref([])
|
||||||
|
const favoritePosts = ref([])
|
||||||
const timelineItems = ref([])
|
const timelineItems = ref([])
|
||||||
const timelineFilter = ref('all')
|
const timelineFilter = ref('all')
|
||||||
const filteredTimelineItems = computed(() => {
|
const filteredTimelineItems = computed(() => {
|
||||||
@@ -369,10 +364,17 @@ const subscribed = ref(false)
|
|||||||
const isLoading = ref(true)
|
const isLoading = ref(true)
|
||||||
const tabLoading = ref(false)
|
const tabLoading = ref(false)
|
||||||
const selectedTab = ref(
|
const selectedTab = ref(
|
||||||
['summary', 'timeline', 'following', 'achievements'].includes(route.query.tab)
|
['summary', 'timeline', 'following', 'favorites', 'achievements'].includes(route.query.tab)
|
||||||
? route.query.tab
|
? route.query.tab
|
||||||
: 'summary',
|
: 'summary',
|
||||||
)
|
)
|
||||||
|
const tabs = [
|
||||||
|
{ name: 'summary', label: '总结', icon: 'fas fa-chart-line' },
|
||||||
|
{ name: 'timeline', label: '时间线', icon: 'fas fa-clock' },
|
||||||
|
{ name: 'following', label: '关注', icon: 'fas fa-user-plus' },
|
||||||
|
{ name: 'favorites', label: '收藏', icon: 'fas fa-bookmark' },
|
||||||
|
{ name: 'achievements', label: '勋章', icon: 'fas fa-medal' },
|
||||||
|
]
|
||||||
const followTab = ref('followers')
|
const followTab = ref('followers')
|
||||||
|
|
||||||
const levelInfo = computed(() => {
|
const levelInfo = computed(() => {
|
||||||
@@ -472,6 +474,16 @@ const fetchFollowUsers = async () => {
|
|||||||
followings.value = followingRes.ok ? await followingRes.json() : []
|
followings.value = followingRes.ok ? await followingRes.json() : []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fetchFavorites = async () => {
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/users/${username}/subscribed-posts`)
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
favoritePosts.value = data.map((p) => ({ icon: 'fas fa-bookmark', post: p }))
|
||||||
|
} else {
|
||||||
|
favoritePosts.value = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const loadSummary = async () => {
|
const loadSummary = async () => {
|
||||||
tabLoading.value = true
|
tabLoading.value = true
|
||||||
await fetchSummary()
|
await fetchSummary()
|
||||||
@@ -490,6 +502,12 @@ const loadFollow = async () => {
|
|||||||
tabLoading.value = false
|
tabLoading.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const loadFavorites = async () => {
|
||||||
|
tabLoading.value = true
|
||||||
|
await fetchFavorites()
|
||||||
|
tabLoading.value = false
|
||||||
|
}
|
||||||
|
|
||||||
const fetchAchievements = async () => {
|
const fetchAchievements = async () => {
|
||||||
const res = await fetch(`${API_BASE_URL}/api/medals?userId=${user.value.id}`)
|
const res = await fetch(`${API_BASE_URL}/api/medals?userId=${user.value.id}`)
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
@@ -578,6 +596,8 @@ const init = async () => {
|
|||||||
await loadTimeline()
|
await loadTimeline()
|
||||||
} else if (selectedTab.value === 'following') {
|
} else if (selectedTab.value === 'following') {
|
||||||
await loadFollow()
|
await loadFollow()
|
||||||
|
} else if (selectedTab.value === 'favorites') {
|
||||||
|
await loadFavorites()
|
||||||
} else if (selectedTab.value === 'achievements') {
|
} else if (selectedTab.value === 'achievements') {
|
||||||
await loadAchievements()
|
await loadAchievements()
|
||||||
}
|
}
|
||||||
@@ -596,6 +616,8 @@ watch(selectedTab, async (val) => {
|
|||||||
await loadTimeline()
|
await loadTimeline()
|
||||||
} else if (val === 'following' && followers.value.length === 0 && followings.value.length === 0) {
|
} else if (val === 'following' && followers.value.length === 0 && followings.value.length === 0) {
|
||||||
await loadFollow()
|
await loadFollow()
|
||||||
|
} else if (val === 'favorites' && favoritePosts.value.length === 0) {
|
||||||
|
await loadFavorites()
|
||||||
} else if (val === 'achievements' && medals.value.length === 0) {
|
} else if (val === 'achievements' && medals.value.length === 0) {
|
||||||
await loadAchievements()
|
await loadAchievements()
|
||||||
}
|
}
|
||||||
@@ -749,13 +771,11 @@ watch(selectedTab, async (val) => {
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-tabs {
|
.profile-tabs :deep(.base-tabs-header) {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: calc(var(--header-height) + 1px);
|
top: calc(var(--header-height) + 1px);
|
||||||
z-index: 200;
|
z-index: 200;
|
||||||
background-color: var(--background-color-blur);
|
background-color: var(--background-color-blur);
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
padding: 0 20px;
|
padding: 0 20px;
|
||||||
border-bottom: 1px solid var(--normal-border-color);
|
border-bottom: 1px solid var(--normal-border-color);
|
||||||
scrollbar-width: none;
|
scrollbar-width: none;
|
||||||
@@ -763,7 +783,7 @@ watch(selectedTab, async (val) => {
|
|||||||
backdrop-filter: var(--blur-10);
|
backdrop-filter: var(--blur-10);
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-tabs-item {
|
.profile-tabs :deep(.base-tab-item) {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
@@ -776,11 +796,6 @@ watch(selectedTab, async (val) => {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-tabs-item.selected {
|
|
||||||
color: var(--primary-color);
|
|
||||||
border-bottom: 2px solid var(--primary-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.profile-summary {
|
.profile-summary {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -900,6 +915,10 @@ watch(selectedTab, async (val) => {
|
|||||||
.follow-container {
|
.follow-container {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.favorites-container {
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
.follow-tabs {
|
.follow-tabs {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
@@ -931,7 +950,7 @@ watch(selectedTab, async (val) => {
|
|||||||
height: 100px;
|
height: 100px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-tabs-item {
|
.profile-tabs :deep(.base-tab-item) {
|
||||||
width: 100px;
|
width: 100px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
|
// markdown.js
|
||||||
import hljs from 'highlight.js/lib/common'
|
import hljs from 'highlight.js/lib/common'
|
||||||
|
import MarkdownIt from 'markdown-it'
|
||||||
|
import sanitizeHtml from 'sanitize-html'
|
||||||
|
import { toast } from '../main'
|
||||||
|
import { tiebaEmoji } from './tiebaEmoji'
|
||||||
|
|
||||||
|
// 动态切换 hljs 主题(保持你原有逻辑)
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
const theme =
|
const theme =
|
||||||
document.documentElement.dataset.theme ||
|
document.documentElement.dataset.theme ||
|
||||||
@@ -10,10 +16,8 @@ if (typeof window !== 'undefined') {
|
|||||||
import('highlight.js/styles/atom-one-light.css')
|
import('highlight.js/styles/atom-one-light.css')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
import MarkdownIt from 'markdown-it'
|
|
||||||
import { toast } from '../main'
|
|
||||||
import { tiebaEmoji } from './tiebaEmoji'
|
|
||||||
|
|
||||||
|
/** @section 自定义插件:@mention */
|
||||||
function mentionPlugin(md) {
|
function mentionPlugin(md) {
|
||||||
const mentionReg = /^@\[([^\]]+)\]/
|
const mentionReg = /^@\[([^\]]+)\]/
|
||||||
function mention(state, silent) {
|
function mention(state, silent) {
|
||||||
@@ -27,6 +31,7 @@ function mentionPlugin(md) {
|
|||||||
['href', `/users/${match[1]}`],
|
['href', `/users/${match[1]}`],
|
||||||
['target', '_blank'],
|
['target', '_blank'],
|
||||||
['class', 'mention-link'],
|
['class', 'mention-link'],
|
||||||
|
['rel', 'noopener noreferrer'],
|
||||||
]
|
]
|
||||||
const text = state.push('text', '', 0)
|
const text = state.push('text', '', 0)
|
||||||
text.content = `@${match[1]}`
|
text.content = `@${match[1]}`
|
||||||
@@ -38,6 +43,7 @@ function mentionPlugin(md) {
|
|||||||
md.inline.ruler.before('emphasis', 'mention', mention)
|
md.inline.ruler.before('emphasis', 'mention', mention)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @section 自定义插件:贴吧表情 :tieba123: */
|
||||||
function tiebaEmojiPlugin(md) {
|
function tiebaEmojiPlugin(md) {
|
||||||
md.renderer.rules['tieba-emoji'] = (tokens, idx) => {
|
md.renderer.rules['tieba-emoji'] = (tokens, idx) => {
|
||||||
const name = tokens[idx].content
|
const name = tokens[idx].content
|
||||||
@@ -60,7 +66,7 @@ function tiebaEmojiPlugin(md) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 链接在新窗口打开
|
/** @section 链接外开 */
|
||||||
function linkPlugin(md) {
|
function linkPlugin(md) {
|
||||||
const defaultRender =
|
const defaultRender =
|
||||||
md.renderer.rules.link_open ||
|
md.renderer.rules.link_open ||
|
||||||
@@ -74,7 +80,6 @@ function linkPlugin(md) {
|
|||||||
|
|
||||||
if (hrefIndex >= 0) {
|
if (hrefIndex >= 0) {
|
||||||
const href = token.attrs[hrefIndex][1]
|
const href = token.attrs[hrefIndex][1]
|
||||||
// 如果是外部链接,添加 target="_blank" 和 rel="noopener noreferrer"
|
|
||||||
if (href.startsWith('http://') || href.startsWith('https://')) {
|
if (href.startsWith('http://') || href.startsWith('https://')) {
|
||||||
token.attrPush(['target', '_blank'])
|
token.attrPush(['target', '_blank'])
|
||||||
token.attrPush(['rel', 'noopener noreferrer'])
|
token.attrPush(['rel', 'noopener noreferrer'])
|
||||||
@@ -85,8 +90,9 @@ function linkPlugin(md) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @section MarkdownIt 实例:开启 HTML,但配合强净化 */
|
||||||
const md = new MarkdownIt({
|
const md = new MarkdownIt({
|
||||||
html: false,
|
html: true, // ⭐ 允许行内 HTML(为 <video> 服务)
|
||||||
linkify: true,
|
linkify: true,
|
||||||
breaks: true,
|
breaks: true,
|
||||||
highlight: (str, lang) => {
|
highlight: (str, lang) => {
|
||||||
@@ -100,16 +106,105 @@ const md = new MarkdownIt({
|
|||||||
.trim()
|
.trim()
|
||||||
.split('\n')
|
.split('\n')
|
||||||
.map(() => `<div class="line-number"></div>`)
|
.map(() => `<div class="line-number"></div>`)
|
||||||
|
// 保留你原有的 CodeBlock + 复制按钮 + 行号结构
|
||||||
return `<pre class="code-block"><button class="copy-code-btn">Copy</button><div class="line-numbers">${lineNumbers.join('')}</div><code class="hljs language-${lang || ''}">${code.trim()}</code></pre>`
|
return `<pre class="code-block"><button class="copy-code-btn">Copy</button><div class="line-numbers">${lineNumbers.join('')}</div><code class="hljs language-${lang || ''}">${code.trim()}</code></pre>`
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
md.use(mentionPlugin)
|
md.use(mentionPlugin)
|
||||||
md.use(tiebaEmojiPlugin)
|
md.use(tiebaEmojiPlugin)
|
||||||
md.use(linkPlugin) // 添加链接插件
|
md.use(linkPlugin)
|
||||||
|
|
||||||
|
/** @section sanitize-html 配置:只白名单需要的标签/属性/类名 */
|
||||||
|
const SANITIZE_CFG = {
|
||||||
|
// 允许的标签(包含你代码块里用到的 button/div)
|
||||||
|
allowedTags: [
|
||||||
|
'a',
|
||||||
|
'p',
|
||||||
|
'div',
|
||||||
|
'span',
|
||||||
|
'pre',
|
||||||
|
'code',
|
||||||
|
'button',
|
||||||
|
'img',
|
||||||
|
'br',
|
||||||
|
'hr',
|
||||||
|
'blockquote',
|
||||||
|
'strong',
|
||||||
|
'em',
|
||||||
|
'ul',
|
||||||
|
'ol',
|
||||||
|
'li',
|
||||||
|
'h1',
|
||||||
|
'h2',
|
||||||
|
'h3',
|
||||||
|
'h4',
|
||||||
|
'h5',
|
||||||
|
'h6',
|
||||||
|
'table',
|
||||||
|
'thead',
|
||||||
|
'tbody',
|
||||||
|
'tr',
|
||||||
|
'td',
|
||||||
|
'th',
|
||||||
|
'video',
|
||||||
|
'source',
|
||||||
|
],
|
||||||
|
// 允许的属性
|
||||||
|
allowedAttributes: {
|
||||||
|
a: ['href', 'name', 'target', 'rel', 'class'],
|
||||||
|
img: ['src', 'alt', 'title', 'width', 'height', 'class'],
|
||||||
|
div: ['class'],
|
||||||
|
span: ['class'],
|
||||||
|
pre: ['class'],
|
||||||
|
code: ['class'],
|
||||||
|
button: ['class'],
|
||||||
|
video: [
|
||||||
|
'controls',
|
||||||
|
'autoplay',
|
||||||
|
'muted',
|
||||||
|
'loop',
|
||||||
|
'playsinline',
|
||||||
|
'poster',
|
||||||
|
'preload',
|
||||||
|
'width',
|
||||||
|
'height',
|
||||||
|
'crossorigin',
|
||||||
|
],
|
||||||
|
source: ['src', 'type'],
|
||||||
|
},
|
||||||
|
// 允许的类名(保留你的样式钩子)
|
||||||
|
allowedClasses: {
|
||||||
|
a: ['mention-link'],
|
||||||
|
img: ['emoji'],
|
||||||
|
pre: ['code-block'],
|
||||||
|
div: ['line-numbers', 'line-number'],
|
||||||
|
code: ['hljs', /^language-/],
|
||||||
|
button: ['copy-code-btn'],
|
||||||
|
},
|
||||||
|
// 允许的协议(视频可能是 blob: / data:)
|
||||||
|
allowedSchemes: ['http', 'https', 'data', 'blob'],
|
||||||
|
allowProtocolRelative: false,
|
||||||
|
// 统一移除所有 on* 事件、style 等(默认就会清理)
|
||||||
|
transformTags: {
|
||||||
|
// 没写 controls 的 video,强制加上(避免静默自动播放)
|
||||||
|
video: (tagName, attribs) => {
|
||||||
|
const attrs = { ...attribs }
|
||||||
|
if (!('controls' in attrs)) attrs.controls = 'controls'
|
||||||
|
// 安全建议:若允许 autoplay,默认要求 muted
|
||||||
|
if ('autoplay' in attrs && !('muted' in attrs)) {
|
||||||
|
attrs.muted = 'muted'
|
||||||
|
}
|
||||||
|
return { tagName, attribs: attrs }
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @section 渲染 & 事件 */
|
||||||
export function renderMarkdown(text) {
|
export function renderMarkdown(text) {
|
||||||
return md.render(text || '')
|
const raw = md.render(text || '')
|
||||||
|
// ⭐ 核心:对最终 HTML 进行一次净化
|
||||||
|
return sanitizeHtml(raw, SANITIZE_CFG)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function handleMarkdownClick(e) {
|
export function handleMarkdownClick(e) {
|
||||||
@@ -124,20 +219,16 @@ export function handleMarkdownClick(e) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @section 纯文本提取(保持你原有“统一正则法”) */
|
||||||
export function stripMarkdown(text) {
|
export function stripMarkdown(text) {
|
||||||
const html = md.render(text || '')
|
const html = md.render(text || '')
|
||||||
|
|
||||||
// 统一使用正则表达式方法,确保服务端和客户端行为一致
|
|
||||||
let plainText = html.replace(/<[^>]+>/g, '')
|
let plainText = html.replace(/<[^>]+>/g, '')
|
||||||
|
|
||||||
// 标准化空白字符处理
|
|
||||||
plainText = plainText
|
plainText = plainText
|
||||||
.replace(/\r\n/g, '\n') // Windows换行符转为Unix格式
|
.replace(/\r\n/g, '\n')
|
||||||
.replace(/\r/g, '\n') // 旧Mac换行符转为Unix格式
|
.replace(/\r/g, '\n')
|
||||||
.replace(/[ \t]+/g, ' ') // 合并空格和制表符为单个空格
|
.replace(/[ \t]+/g, ' ')
|
||||||
.replace(/\n{3,}/g, '\n\n') // 最多保留两个连续换行(一个空行)
|
.replace(/\n{3,}/g, '\n\n')
|
||||||
.trim()
|
.trim()
|
||||||
|
|
||||||
return plainText
|
return plainText
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,6 +237,5 @@ export function stripMarkdownLength(text, length) {
|
|||||||
if (!length || plain.length <= length) {
|
if (!length || plain.length <= length) {
|
||||||
return plain
|
return plain
|
||||||
}
|
}
|
||||||
// 截断并加省略号
|
|
||||||
return plain.slice(0, length) + '...'
|
return plain.slice(0, length) + '...'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export function createVditor(editorId, options = {}) {
|
|||||||
const { placeholder = '', preview = {}, input, after } = options
|
const { placeholder = '', preview = {}, input, after } = options
|
||||||
const config = useRuntimeConfig()
|
const config = useRuntimeConfig()
|
||||||
const API_BASE_URL = config.public.apiBaseUrl
|
const API_BASE_URL = config.public.apiBaseUrl
|
||||||
|
const WEBSITE_BASE_URL = config.public.websiteBaseUrl
|
||||||
|
|
||||||
const fetchMentions = async (value) => {
|
const fetchMentions = async (value) => {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
@@ -80,7 +81,7 @@ export function createVditor(editorId, options = {}) {
|
|||||||
}))
|
}))
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
vditorPostCitation(API_BASE_URL),
|
vditorPostCitation(API_BASE_URL, WEBSITE_BASE_URL),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
cdn: 'https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/vditor',
|
cdn: 'https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/vditor',
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ async function searchPost(apiBaseUrl, keyword) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export default (apiBaseUrl) => {
|
export default (apiBaseUrl, websiteBaseUrl) => {
|
||||||
return {
|
return {
|
||||||
key: '#',
|
key: '#',
|
||||||
hint: async (keyword) => {
|
hint: async (keyword) => {
|
||||||
@@ -22,8 +22,8 @@ export default (apiBaseUrl) => {
|
|||||||
let value = ''
|
let value = ''
|
||||||
return (
|
return (
|
||||||
body.map((item) => ({
|
body.map((item) => ({
|
||||||
value: `[${item.title}](/posts/${item.id})`,
|
value: `[🔗${item.title}](${websiteBaseUrl}/posts/${item.id})`,
|
||||||
html: `<div>${item.title}</div>`,
|
html: `<div><i class="fas fa-link"></i> ${item.title}</div>`,
|
||||||
})) ?? []
|
})) ?? []
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
Reference in New Issue
Block a user