mirror of
https://github.com/nagisa77/OpenIsle.git
synced 2026-02-13 10:30:58 +08:00
Compare commits
5 Commits
feature/CO
...
codex/refa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b6c2471bc3 | ||
|
|
4cc2800f09 | ||
|
|
396434a82e | ||
|
|
07c6b53f82 | ||
|
|
930a861ba6 |
@@ -4,6 +4,8 @@
|
|||||||
- [配置环境变量](#配置环境变量)
|
- [配置环境变量](#配置环境变量)
|
||||||
- [配置 IDEA 参数](#配置-idea-参数)
|
- [配置 IDEA 参数](#配置-idea-参数)
|
||||||
- [配置 MySQL](#配置-mysql)
|
- [配置 MySQL](#配置-mysql)
|
||||||
|
- [配置 Redis](#配置-redis)
|
||||||
|
- [配置 RabbitMQ](#配置-rabbitmq)
|
||||||
- [Docker 环境](#docker-环境)
|
- [Docker 环境](#docker-环境)
|
||||||
- [配置环境变量](#配置环境变量-1)
|
- [配置环境变量](#配置环境变量-1)
|
||||||
- [构建并启动镜像](#构建并启动镜像)
|
- [构建并启动镜像](#构建并启动镜像)
|
||||||
@@ -117,14 +119,75 @@ SERVER_PORT=8082
|
|||||||
|
|
||||||
#### 配置 Redis
|
#### 配置 Redis
|
||||||
|
|
||||||
填写环境变量 `.env` 中的 Redis 相关配置并启动 Redis
|
后端的登录态缓存、访问频控等都依赖 Redis,请确保本地有可用的 Redis 实例。
|
||||||
|
|
||||||
```ini
|
1. **启动 Redis 服务**(已有服务可跳过)
|
||||||
REDIS_HOST=<Redis 地址>
|
|
||||||
REDIS_PORT=<Redis 端口>
|
|
||||||
```
|
|
||||||
|
|
||||||
处理完环境问题直接跑起来就能通了
|
```bash
|
||||||
|
docker run --name openisle-redis -p 6379:6379 -d redis:7-alpine
|
||||||
|
```
|
||||||
|
|
||||||
|
该命令会在本机暴露 `6379` 端口。若你已有其他端口的 Redis,可以根据实际情况调整映射关系。
|
||||||
|
|
||||||
|
2. **在 `backend/open-isle.env` 中填写连接信息**
|
||||||
|
|
||||||
|
```ini
|
||||||
|
REDIS_HOST=127.0.0.1
|
||||||
|
REDIS_PORT=6379
|
||||||
|
# 可选:若需要切换逻辑库,可新增此变量,默认使用 0 号库
|
||||||
|
REDIS_DATABASE=0
|
||||||
|
```
|
||||||
|
|
||||||
|
`application.properties` 中的默认值为 `localhost:6379`、数据库 `0`,如果你的环境恰好一致,也可以不额外填写;显式声明可以避免 IDE/运行时读取到意外配置。
|
||||||
|
|
||||||
|
3. **验证连接**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
redis-cli -h 127.0.0.1 -p 6379 ping
|
||||||
|
```
|
||||||
|
|
||||||
|
启动后端后,日志中会出现 `Redis connection established ...`(来自 `RedisConnectionLogger`),说明已成功连通。
|
||||||
|
|
||||||
|
#### 配置 RabbitMQ
|
||||||
|
|
||||||
|
消息通知和 WebSocket 推送链路依赖 RabbitMQ。后端会自动声明交换机与队列,确保本地 RabbitMQ 可用即可。
|
||||||
|
|
||||||
|
1. **启动 RabbitMQ 服务**(推荐包含管理界面)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run --name openisle-rabbitmq \
|
||||||
|
-e RABBITMQ_DEFAULT_USER=openisle \
|
||||||
|
-e RABBITMQ_DEFAULT_PASS=openisle \
|
||||||
|
-p 5672:5672 -p 15672:15672 \
|
||||||
|
-d rabbitmq:3.13-management
|
||||||
|
```
|
||||||
|
|
||||||
|
管理界面位于 http://127.0.0.1:15672 ,可用于查看队列、交换机等资源。
|
||||||
|
|
||||||
|
2. **同步填写后端与 WebSocket 服务的环境变量**
|
||||||
|
|
||||||
|
```ini
|
||||||
|
# backend/open-isle.env
|
||||||
|
RABBITMQ_HOST=127.0.0.1
|
||||||
|
RABBITMQ_PORT=5672
|
||||||
|
RABBITMQ_USERNAME=openisle
|
||||||
|
RABBITMQ_PASSWORD=openisle
|
||||||
|
|
||||||
|
# 如果需要启动 websocket_service,也需要在 websocket_service.env 中保持一致
|
||||||
|
```
|
||||||
|
|
||||||
|
如果沿用 RabbitMQ 默认的 `guest/guest`,可以不显式设置,Spring Boot 会回退到 `application.properties` 中的默认值 (`localhost:5672`、`guest/guest`、虚拟主机 `/`)。
|
||||||
|
|
||||||
|
3. **确认自动声明的资源**
|
||||||
|
|
||||||
|
- 交换机:`openisle-exchange`
|
||||||
|
- 旧版兼容队列:`notifications-queue`
|
||||||
|
- 分片队列:`notifications-queue-0` ~ `notifications-queue-f`(共 16 个,对应路由键 `notifications.shard.0` ~ `notifications.shard.f`)
|
||||||
|
- 队列持久化默认开启,来自 `rabbitmq.queue.durable=true`,如需仅在本地短暂测试,可在 `application.properties` 中调整该配置。
|
||||||
|
|
||||||
|
启动后端时可在日志中看到 `=== 开始主动声明 RabbitMQ 组件 ===` 与后续的声明结果,也可以在管理界面中查看是否创建成功。
|
||||||
|
|
||||||
|
完成 Redis 与 RabbitMQ 配置后,即可继续启动后端服务。
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.openisle.dto;
|
package com.openisle.dto;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
|
|
||||||
/** Lightweight post metadata used in user profile lists. */
|
/** Lightweight post metadata used in user profile lists. */
|
||||||
@@ -11,6 +12,8 @@ public class PostMetaDto {
|
|||||||
private String title;
|
private String title;
|
||||||
private String snippet;
|
private String snippet;
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
private String category;
|
private CategoryDto category;
|
||||||
|
private List<TagDto> tags;
|
||||||
private long views;
|
private long views;
|
||||||
|
private long commentCount;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import com.openisle.model.Comment;
|
|||||||
import com.openisle.model.Post;
|
import com.openisle.model.Post;
|
||||||
import com.openisle.model.User;
|
import com.openisle.model.User;
|
||||||
import com.openisle.service.*;
|
import com.openisle.service.*;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.security.core.Authentication;
|
import org.springframework.security.core.Authentication;
|
||||||
@@ -23,8 +24,10 @@ public class UserMapper {
|
|||||||
private final PostReadService postReadService;
|
private final PostReadService postReadService;
|
||||||
private final LevelService levelService;
|
private final LevelService levelService;
|
||||||
private final MedalService medalService;
|
private final MedalService medalService;
|
||||||
|
private final TagMapper tagMapper;
|
||||||
|
private final CategoryMapper categoryMapper;
|
||||||
|
|
||||||
@Value("${app.snippet-length:50}")
|
@Value("${app.snippet-length}")
|
||||||
private int snippetLength;
|
private int snippetLength;
|
||||||
|
|
||||||
public AuthorDto toAuthorDto(User user) {
|
public AuthorDto toAuthorDto(User user) {
|
||||||
@@ -88,7 +91,12 @@ public class UserMapper {
|
|||||||
dto.setSnippet(content);
|
dto.setSnippet(content);
|
||||||
}
|
}
|
||||||
dto.setCreatedAt(post.getCreatedAt());
|
dto.setCreatedAt(post.getCreatedAt());
|
||||||
dto.setCategory(post.getCategory().getName());
|
dto.setCategory(categoryMapper.toDto(post.getCategory()));
|
||||||
|
dto.setTags(post.getTags().stream().map(tagMapper::toDto).collect(Collectors.toList()));
|
||||||
|
if (post.getLastReplyAt() == null) {
|
||||||
|
commentService.updatePostCommentStats(post);
|
||||||
|
}
|
||||||
|
dto.setCommentCount(post.getCommentCount());
|
||||||
dto.setViews(post.getViews());
|
dto.setViews(post.getViews());
|
||||||
return dto;
|
return dto;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ public class SearchService {
|
|||||||
private final CategoryRepository categoryRepository;
|
private final CategoryRepository categoryRepository;
|
||||||
private final TagRepository tagRepository;
|
private final TagRepository tagRepository;
|
||||||
|
|
||||||
@org.springframework.beans.factory.annotation.Value("${app.snippet-length:50}")
|
@org.springframework.beans.factory.annotation.Value("${app.snippet-length}")
|
||||||
private int snippetLength;
|
private int snippetLength;
|
||||||
|
|
||||||
public List<User> searchUsers(String keyword) {
|
public List<User> searchUsers(String keyword) {
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ app.avatar.base-url=${AVATAR_BASE_URL:https://api.dicebear.com/6.x}
|
|||||||
app.user.posts-limit=${USER_POSTS_LIMIT:10}
|
app.user.posts-limit=${USER_POSTS_LIMIT:10}
|
||||||
app.user.replies-limit=${USER_REPLIES_LIMIT:50}
|
app.user.replies-limit=${USER_REPLIES_LIMIT:50}
|
||||||
# Length of extracted snippets for posts and search (-1 to disable truncation)
|
# Length of extracted snippets for posts and search (-1 to disable truncation)
|
||||||
app.snippet-length=${SNIPPET_LENGTH:50}
|
app.snippet-length=${SNIPPET_LENGTH:200}
|
||||||
|
|
||||||
# Captcha configuration
|
# Captcha configuration
|
||||||
app.captcha.enabled=${CAPTCHA_ENABLED:false}
|
app.captcha.enabled=${CAPTCHA_ENABLED:false}
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ body {
|
|||||||
font-family: 'WenQuanYi Micro Hei', 'Helvetica Neue', Arial, sans-serif;
|
font-family: 'WenQuanYi Micro Hei', 'Helvetica Neue', Arial, sans-serif;
|
||||||
background-color: var(--normal-background-color);
|
background-color: var(--normal-background-color);
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
|
text-underline-offset: 4px;
|
||||||
/* 禁止滚动 */
|
/* 禁止滚动 */
|
||||||
/* overflow: hidden; */
|
/* overflow: hidden; */
|
||||||
}
|
}
|
||||||
|
|||||||
168
frontend_nuxt/components/ProfileTimelineCommentGroup.vue
Normal file
168
frontend_nuxt/components/ProfileTimelineCommentGroup.vue
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
<template>
|
||||||
|
<div class="timeline-container">
|
||||||
|
<div class="timeline-header">
|
||||||
|
<div class="timeline-title">{{ headerText }}</div>
|
||||||
|
<div class="timeline-date">{{ headerDate }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="comment-content">
|
||||||
|
<div v-for="entry in entries" :key="entry.comment.id" class="comment-content-item">
|
||||||
|
<div class="comment-content-item-main">
|
||||||
|
<comment-one class="comment-content-item-icon" />
|
||||||
|
<template v-if="!entry.comment.parentComment">
|
||||||
|
<span class="comment-prefix">
|
||||||
|
在
|
||||||
|
<NuxtLink :to="entry.postLink" class="timeline-link">
|
||||||
|
{{ entry.comment.post.title }}
|
||||||
|
</NuxtLink>
|
||||||
|
下评论了
|
||||||
|
</span>
|
||||||
|
<NuxtLink :to="entry.commentLink" class="timeline-comment-link">
|
||||||
|
{{ stripContent(entry.comment.content) }}
|
||||||
|
</NuxtLink>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<span class="comment-prefix">
|
||||||
|
在
|
||||||
|
<NuxtLink :to="entry.postLink" class="timeline-link">
|
||||||
|
{{ entry.comment.post.title }}
|
||||||
|
</NuxtLink>
|
||||||
|
下对
|
||||||
|
<NuxtLink :to="entry.parentLink" class="timeline-link">
|
||||||
|
{{ stripContent(entry.comment.parentComment.content) }}
|
||||||
|
</NuxtLink>
|
||||||
|
回复了
|
||||||
|
</span>
|
||||||
|
<NuxtLink :to="entry.commentLink" class="timeline-comment-link">
|
||||||
|
{{ stripContent(entry.comment.content) }}
|
||||||
|
</NuxtLink>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<div class="timeline-date">{{ formatDate(entry.createdAt) }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { stripMarkdownLength } from '~/utils/markdown'
|
||||||
|
import TimeManager from '~/utils/time'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
item: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const entries = computed(() =>
|
||||||
|
(props.item.entries || []).map((entry) => ({
|
||||||
|
...entry,
|
||||||
|
postLink: `/posts/${entry.comment.post.id}`,
|
||||||
|
commentLink: `/posts/${entry.comment.post.id}#comment-${entry.comment.id}`,
|
||||||
|
parentLink: entry.comment.parentComment
|
||||||
|
? `/posts/${entry.comment.post.id}#comment-${entry.comment.parentComment.id}`
|
||||||
|
: undefined,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
|
||||||
|
const commentCount = computed(
|
||||||
|
() => entries.value.filter((entry) => !entry.comment.parentComment).length,
|
||||||
|
)
|
||||||
|
|
||||||
|
const replyCount = computed(
|
||||||
|
() => entries.value.filter((entry) => entry.comment.parentComment).length,
|
||||||
|
)
|
||||||
|
|
||||||
|
const headerText = computed(() => {
|
||||||
|
if (commentCount.value && replyCount.value) {
|
||||||
|
return `发布了${commentCount.value}条评论和${replyCount.value}条回复`
|
||||||
|
}
|
||||||
|
if (commentCount.value) {
|
||||||
|
return `发布了${commentCount.value}条评论`
|
||||||
|
}
|
||||||
|
if (replyCount.value) {
|
||||||
|
return `发布了${replyCount.value}条回复`
|
||||||
|
}
|
||||||
|
return '发布了评论'
|
||||||
|
})
|
||||||
|
|
||||||
|
const headerDate = computed(() => TimeManager.format(props.item.createdAt))
|
||||||
|
|
||||||
|
const formatDate = (date) => TimeManager.format(date)
|
||||||
|
|
||||||
|
const stripContent = (content) => stripMarkdownLength(content || '', 200)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.timeline-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-title {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-date {
|
||||||
|
color: var(--text-color-secondary);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-content-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-content-item-main {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-content-item-icon {
|
||||||
|
color: var(--primary-color);
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-prefix {
|
||||||
|
color: var(--text-color-secondary);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-comment-link {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: center;
|
||||||
|
color: var(--text-color);
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-comment-link:hover {
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-link {
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-link:hover {
|
||||||
|
color: var(--primary-color-hover);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
79
frontend_nuxt/components/ProfileTimelinePostItem.vue
Normal file
79
frontend_nuxt/components/ProfileTimelinePostItem.vue
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
<template>
|
||||||
|
<div class="timeline-container">
|
||||||
|
<div class="timeline-header">
|
||||||
|
<div class="timeline-title">发布了文章</div>
|
||||||
|
<div class="timeline-date">{{ formattedDate }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="article-container">
|
||||||
|
<NuxtLink :to="postLink" class="timeline-article-link">
|
||||||
|
{{ props.item.post.title }}
|
||||||
|
</NuxtLink>
|
||||||
|
<div class="timeline-snippet">
|
||||||
|
{{ postSnippet }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { stripMarkdown } from '~/utils/markdown'
|
||||||
|
import TimeManager from '~/utils/time'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
item: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const formattedDate = computed(() => TimeManager.format(props.item.createdAt))
|
||||||
|
const postLink = computed(() => `/posts/${props.item.post.id}`)
|
||||||
|
const postSnippet = computed(() => stripMarkdown(props.item.post?.snippet ?? ''))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.timeline-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-title {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-date {
|
||||||
|
color: var(--text-color-secondary);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-article-link {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-article-link:hover {
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-snippet {
|
||||||
|
color: var(--text-color-secondary);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -212,48 +212,9 @@
|
|||||||
<div class="timeline-list">
|
<div class="timeline-list">
|
||||||
<BaseTimeline :items="filteredTimelineItems">
|
<BaseTimeline :items="filteredTimelineItems">
|
||||||
<template #item="{ item }">
|
<template #item="{ item }">
|
||||||
<template v-if="item.type === 'post'">
|
<ProfileTimelinePostItem v-if="item.type === 'post'" :item="item" />
|
||||||
发布了文章
|
<ProfileTimelineCommentGroup v-else-if="item.type === 'comment'" :item="item" />
|
||||||
<NuxtLink :to="`/posts/${item.post.id}`" class="timeline-link">
|
<ProfileTimelineCommentGroup v-else-if="item.type === 'reply'" :item="item" />
|
||||||
{{ 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'">
|
<template v-else-if="item.type === 'tag'">
|
||||||
创建了标签
|
创建了标签
|
||||||
<span class="timeline-link" @click="gotoTag(item.tag)">
|
<span class="timeline-link" @click="gotoTag(item.tag)">
|
||||||
@@ -326,6 +287,8 @@ import BasePlaceholder from '~/components/BasePlaceholder.vue'
|
|||||||
import BaseTimeline from '~/components/BaseTimeline.vue'
|
import BaseTimeline from '~/components/BaseTimeline.vue'
|
||||||
import BaseTabs from '~/components/BaseTabs.vue'
|
import BaseTabs from '~/components/BaseTabs.vue'
|
||||||
import LevelProgress from '~/components/LevelProgress.vue'
|
import LevelProgress from '~/components/LevelProgress.vue'
|
||||||
|
import ProfileTimelineCommentGroup from '~/components/ProfileTimelineCommentGroup.vue'
|
||||||
|
import ProfileTimelinePostItem from '~/components/ProfileTimelinePostItem.vue'
|
||||||
import UserList from '~/components/UserList.vue'
|
import UserList from '~/components/UserList.vue'
|
||||||
import { toast } from '~/main'
|
import { toast } from '~/main'
|
||||||
import { authState, getToken } from '~/utils/auth'
|
import { authState, getToken } from '~/utils/auth'
|
||||||
@@ -431,6 +394,22 @@ const fetchSummary = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isSameDay = (a, b) => {
|
||||||
|
const dateA = new Date(a)
|
||||||
|
const dateB = new Date(b)
|
||||||
|
return (
|
||||||
|
dateA.getFullYear() === dateB.getFullYear() &&
|
||||||
|
dateA.getMonth() === dateB.getMonth() &&
|
||||||
|
dateA.getDate() === dateB.getDate()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const createCommentEntry = (item) => ({
|
||||||
|
type: item.type,
|
||||||
|
comment: item.comment,
|
||||||
|
createdAt: item.createdAt,
|
||||||
|
})
|
||||||
|
|
||||||
const fetchTimeline = async () => {
|
const fetchTimeline = async () => {
|
||||||
const [postsRes, repliesRes, tagsRes] = await Promise.all([
|
const [postsRes, repliesRes, tagsRes] = await Promise.all([
|
||||||
fetch(`${API_BASE_URL}/api/users/${username}/posts?limit=50`),
|
fetch(`${API_BASE_URL}/api/users/${username}/posts?limit=50`),
|
||||||
@@ -461,7 +440,32 @@ const fetchTimeline = async () => {
|
|||||||
})),
|
})),
|
||||||
]
|
]
|
||||||
mapped.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
|
mapped.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
|
||||||
timelineItems.value = mapped
|
|
||||||
|
const grouped = []
|
||||||
|
for (const item of mapped) {
|
||||||
|
if (item.type === 'comment') {
|
||||||
|
const last = grouped[grouped.length - 1]
|
||||||
|
if (last && last.type === 'comment' && isSameDay(last.createdAt, item.createdAt)) {
|
||||||
|
last.entries.push(createCommentEntry(item))
|
||||||
|
if (new Date(item.createdAt) > new Date(last.createdAt)) {
|
||||||
|
last.createdAt = item.createdAt
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
grouped.push({
|
||||||
|
...item,
|
||||||
|
entries: [createCommentEntry(item)],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else if (item.type === 'reply') {
|
||||||
|
grouped.push({
|
||||||
|
...item,
|
||||||
|
entries: [createCommentEntry(item)],
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
grouped.push(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
timelineItems.value = grouped
|
||||||
}
|
}
|
||||||
|
|
||||||
const fetchFollowUsers = async () => {
|
const fetchFollowUsers = async () => {
|
||||||
@@ -903,6 +907,7 @@ watch(selectedTab, async (val) => {
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: gray;
|
color: gray;
|
||||||
margin-top: 5px;
|
margin-top: 5px;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.timeline-snippet {
|
.timeline-snippet {
|
||||||
@@ -939,6 +944,81 @@ watch(selectedTab, async (val) => {
|
|||||||
padding: 40px 0;
|
padding: 40px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ttimeline-container {
|
||||||
|
margin-top: 2px;
|
||||||
|
padding-bottom: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-header {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
margin-top: 10px;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-content-item-main {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
gap: 5px;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-content-item-icon {
|
||||||
|
width: 20px;
|
||||||
|
height: 25px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-content-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-comment-link {
|
||||||
|
color: var(--text-color);
|
||||||
|
word-break: break-word;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-comment-link:hover {
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-article-link {
|
||||||
|
color: var(--text-color);
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 20px;
|
||||||
|
word-break: break-word;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-article-link:hover {
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-container {
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid var(--normal-border-color);
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
.follow-container {
|
.follow-container {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
Moon,
|
Moon,
|
||||||
ComputerOne,
|
ComputerOne,
|
||||||
Comment,
|
Comment,
|
||||||
|
CommentOne,
|
||||||
Link,
|
Link,
|
||||||
SlyFaceWhitSmile,
|
SlyFaceWhitSmile,
|
||||||
Like,
|
Like,
|
||||||
@@ -103,6 +104,7 @@ export default defineNuxtPlugin((nuxtApp) => {
|
|||||||
nuxtApp.vueApp.component('Moon', Moon)
|
nuxtApp.vueApp.component('Moon', Moon)
|
||||||
nuxtApp.vueApp.component('ComputerOne', ComputerOne)
|
nuxtApp.vueApp.component('ComputerOne', ComputerOne)
|
||||||
nuxtApp.vueApp.component('CommentIcon', Comment)
|
nuxtApp.vueApp.component('CommentIcon', Comment)
|
||||||
|
nuxtApp.vueApp.component('CommentOne', CommentOne)
|
||||||
nuxtApp.vueApp.component('LinkIcon', Link)
|
nuxtApp.vueApp.component('LinkIcon', Link)
|
||||||
nuxtApp.vueApp.component('SlyFaceWhitSmile', SlyFaceWhitSmile)
|
nuxtApp.vueApp.component('SlyFaceWhitSmile', SlyFaceWhitSmile)
|
||||||
nuxtApp.vueApp.component('Like', Like)
|
nuxtApp.vueApp.component('Like', Like)
|
||||||
|
|||||||
Reference in New Issue
Block a user