Compare commits

...

23 Commits

Author SHA1 Message Date
tim
9234d1099e fix: 修复整个按钮都在转的问题 2025-09-23 23:14:32 +08:00
Tim
373dece19d Merge pull request #1012 from nagisa77/feature/loading_icon
fix: loading icon
2025-09-19 23:21:48 +08:00
tim
b09828bcc2 fix: loading icon 2025-09-19 23:20:50 +08:00
Tim
8751a7707c Merge pull request #1010 from nagisa77/codex/update-overview-page-to-display-api-paths
feat(docs): show API routes on overview
2025-09-19 18:03:18 +08:00
Tim
f91b240802 feat(docs): show API routes on overview 2025-09-19 17:57:44 +08:00
Tim
062b289f7a Revert "fix: 新增openAPI配置选项"
This reverts commit c1dc77f6db.
2025-09-19 17:47:41 +08:00
Tim
c1dc77f6db fix: 新增openAPI配置选项 2025-09-19 16:46:28 +08:00
Tim
cea60175c2 fix: basetimeline 去除hover属性 2025-09-19 16:39:10 +08:00
Tim
2bd3630512 Merge pull request #1008 from nagisa77/feature/user_page_timeline
user page timeline
2025-09-19 16:22:20 +08:00
tim
a9d8181940 fix: timeline ui 重构 2025-09-19 16:21:19 +08:00
Tim
4cc108094d Merge pull request #1009 from nagisa77/codex/integrate-timelinetagitem-and-refactor-components
feat: extract timeline tag item component
2025-09-19 13:50:15 +08:00
Tim
bfa57cce44 feat: extract timeline tag item component 2025-09-19 13:44:37 +08:00
tim
8ebdcd94f5 fix: timeline 继承标签介绍 2025-09-19 11:30:58 +08:00
tim
9991210db2 fix: 部分ui修改 2025-09-19 11:21:27 +08:00
Tim
1c59815afa Merge pull request #1007 from nagisa77/codex/refactor-user-posts-display-components-aopsvr
Enhance user timeline post metadata and grouping
2025-09-19 00:32:17 +08:00
Tim
e7593c8ebf Enhance user timeline post metadata and grouping 2025-09-19 00:31:52 +08:00
tim
bc767a6ac9 Revert "Enhance user timeline grouping and post metadata"
This reverts commit b6c2471bc3.
2025-09-19 00:31:24 +08:00
Tim
1c1915285d Merge pull request #1006 from nagisa77/codex/refactor-user-posts-display-components
Enhance user timeline grouping and post metadata
2025-09-19 00:22:58 +08:00
Tim
b6c2471bc3 Enhance user timeline grouping and post metadata 2025-09-19 00:22:34 +08:00
tim
4cc2800f09 feat: timeline 基础格式更新 2025-09-18 20:48:46 +08:00
Tim
396434a82e Merge pull request #1005 from nagisa77/codex/add-redis/rabbitmq-configuration-details-to-contributing.md
docs: expand Redis and RabbitMQ setup guidance
2025-09-18 17:50:28 +08:00
Tim
07c6b53f82 docs: document redis and rabbitmq setup 2025-09-18 17:48:32 +08:00
Tim
930a861ba6 Merge pull request #1002 from nagisa77/feature/CONTRIBUTING_openAPI
feat: CONTRIBUTING.md 新增 OpenAPI 介绍 #923
2025-09-18 17:36:48 +08:00
24 changed files with 894 additions and 98 deletions

View File

@@ -4,6 +4,8 @@
- [配置环境变量](#配置环境变量)
- [配置 IDEA 参数](#配置-idea-参数)
- [配置 MySQL](#配置-mysql)
- [配置 Redis](#配置-redis)
- [配置 RabbitMQ](#配置-rabbitmq)
- [Docker 环境](#docker-环境)
- [配置环境变量](#配置环境变量-1)
- [构建并启动镜像](#构建并启动镜像)
@@ -117,14 +119,75 @@ SERVER_PORT=8082
#### 配置 Redis
填写环境变量 `.env` 中的 Redis 相关配置并启动 Redis
后端的登录态缓存、访问频控等都依赖 Redis请确保本地有可用的 Redis 实例。
```ini
REDIS_HOST=<Redis 地址>
REDIS_PORT=<Redis 端口>
```
1. **启动 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 配置后,即可继续启动后端服务。
![运行画面](assets/contributing/backend_img_4.png)

View File

@@ -1,6 +1,7 @@
package com.openisle.dto;
import java.time.LocalDateTime;
import java.util.List;
import lombok.Data;
/** Lightweight post metadata used in user profile lists. */
@@ -11,6 +12,8 @@ public class PostMetaDto {
private String title;
private String snippet;
private LocalDateTime createdAt;
private String category;
private CategoryDto category;
private List<TagDto> tags;
private long views;
private long commentCount;
}

View File

@@ -5,6 +5,7 @@ import com.openisle.model.Comment;
import com.openisle.model.Post;
import com.openisle.model.User;
import com.openisle.service.*;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.Authentication;
@@ -23,8 +24,10 @@ public class UserMapper {
private final PostReadService postReadService;
private final LevelService levelService;
private final MedalService medalService;
private final CategoryMapper categoryMapper;
private final TagMapper tagMapper;
@Value("${app.snippet-length:50}")
@Value("${app.snippet-length}")
private int snippetLength;
public AuthorDto toAuthorDto(User user) {
@@ -88,8 +91,10 @@ public class UserMapper {
dto.setSnippet(content);
}
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()));
dto.setViews(post.getViews());
dto.setCommentCount(post.getCommentCount());
return dto;
}

View File

@@ -28,7 +28,7 @@ public class SearchService {
private final CategoryRepository categoryRepository;
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;
public List<User> searchUsers(String keyword) {

View File

@@ -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.replies-limit=${USER_REPLIES_LIMIT:50}
# 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
app.captcha.enabled=${CAPTCHA_ENABLED:false}

View File

@@ -8,6 +8,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
import com.openisle.dto.CommentInfoDto;
import com.openisle.dto.PostMetaDto;
import com.openisle.dto.UserDto;
import com.openisle.mapper.CategoryMapper;
import com.openisle.mapper.TagMapper;
import com.openisle.mapper.UserMapper;
import com.openisle.model.User;
@@ -64,6 +65,9 @@ class UserControllerTest {
@MockBean
private TagMapper tagMapper;
@MockBean
private CategoryMapper categoryMapper;
@Test
void getCurrentUser() throws Exception {
User u = new User();

View File

@@ -0,0 +1,65 @@
import Link from "next/link";
import { getOpenAPIOperations } from "@/lib/openapi-operations";
const methodColors: Record<string, string> = {
GET: "bg-emerald-100 text-emerald-700",
POST: "bg-blue-100 text-blue-700",
PUT: "bg-amber-100 text-amber-700",
PATCH: "bg-purple-100 text-purple-700",
DELETE: "bg-rose-100 text-rose-700",
};
function MethodBadge({ method }: { method: string }) {
const color = methodColors[method] ?? "bg-slate-100 text-slate-700";
return (
<span
className={`font-semibold uppercase tracking-wide text-xs px-2 py-1 rounded ${color}`}
>
{method}
</span>
);
}
export function APIOverviewTable() {
const operations = getOpenAPIOperations();
if (operations.length === 0) {
return null;
}
return (
<div className="not-prose mt-6 overflow-x-auto">
<table className="w-full border-separate border-spacing-y-2 text-sm">
<thead className="text-left text-muted-foreground">
<tr>
<th className="px-3 py-2 font-medium"></th>
<th className="px-3 py-2 font-medium"></th>
<th className="px-3 py-2 font-medium"></th>
</tr>
</thead>
<tbody>
{operations.map((operation) => (
<tr
key={`${operation.method}-${operation.route}`}
className="bg-muted/30"
>
<td className="px-3 py-2 align-top font-mono">
<Link className="hover:underline" href={operation.href}>
{operation.route}
</Link>
</td>
<td className="px-3 py-2 align-top">
<MethodBadge method={operation.method} />
</td>
<td className="px-3 py-2 align-top text-muted-foreground">
{operation.summary || "—"}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}

View File

@@ -2,3 +2,11 @@
title: API 概览
description: Open API 接口文档
---
import { APIOverviewTable } from "@/components/api-overview";
# 接口列表
以下列表聚合了所有已生成的接口页面,展示对应的路径、请求方法以及摘要,便于快速检索和跳转。
<APIOverviewTable />

View File

@@ -0,0 +1,68 @@
import matter from "gray-matter";
import { source } from "@/lib/source";
interface OperationFrontmatter {
title?: string;
description?: string;
_openapi?: {
method?: string;
route?: string;
};
}
export interface OpenAPIOperation {
href: string;
method: string;
route: string;
summary: string;
}
function parseFrontmatter(content: string): OperationFrontmatter {
const result = matter(content);
return result.data as OperationFrontmatter;
}
function normalizeSummary(frontmatter: OperationFrontmatter): string {
return frontmatter.title ?? frontmatter.description ?? "";
}
export function getOpenAPIOperations(): OpenAPIOperation[] {
return source
.getPages()
.filter((page) =>
page.url.startsWith("/openapi/") && page.url !== "/openapi"
)
.map((page) => {
if (typeof page.data.content !== "string") {
return undefined;
}
const frontmatter = parseFrontmatter(page.data.content);
const method = frontmatter._openapi?.method?.toUpperCase();
const route = frontmatter._openapi?.route;
const summary = normalizeSummary(frontmatter);
if (!method || !route) {
return undefined;
}
return {
href: page.url,
method,
route,
summary,
} satisfies OpenAPIOperation;
})
.filter((operation): operation is OpenAPIOperation => Boolean(operation))
.sort((a, b) => {
const routeCompare = a.route.localeCompare(b.route);
if (routeCompare !== 0) {
return routeCompare;
}
return a.method.localeCompare(b.method);
});
}

View File

@@ -1,10 +1,18 @@
import { rmSync } from "node:fs";
import { generateFiles } from "fumadocs-openapi";
import { openapi } from "@/lib/openapi";
const outputDir = "./content/docs/openapi/(generated)";
rmSync(outputDir, { recursive: true, force: true });
void generateFiles({
input: openapi,
output: "./content/docs/openapi/(generated)",
output: outputDir,
// we recommend to enable it
// make sure your endpoint description doesn't break MDX syntax.
includeDescription: true,
per: "operation",
groupBy: "route",
});

View File

@@ -94,6 +94,7 @@ body {
font-family: 'WenQuanYi Micro Hei', 'Helvetica Neue', Arial, sans-serif;
background-color: var(--normal-background-color);
color: var(--text-color);
text-underline-offset: 4px;
/* 禁止滚动 */
/* overflow: hidden; */
}
@@ -120,6 +121,19 @@ body {
vertical-align: middle;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.loading-icon {
animation: spin 1s linear infinite;
}
/* .vditor {
--textarea-background-color: transparent;
border: none !important;

View File

@@ -1,5 +1,5 @@
<template>
<div class="timeline" :class="{ 'hover-enabled': hover }">
<div class="timeline">
<div class="timeline-item" v-for="(item, idx) in items" :key="idx">
<div
class="timeline-icon"
@@ -26,7 +26,6 @@ export default {
name: 'BaseTimeline',
props: {
items: { type: Array, default: () => [] },
hover: { type: Boolean, default: false },
},
}
</script>
@@ -46,12 +45,6 @@ export default {
margin-top: 10px;
}
.hover-enabled .timeline-item:hover {
background-color: var(--menu-selected-background-color);
transition: background-color 0.2s;
border-radius: 10px;
}
.timeline-icon {
position: sticky;
top: 0;
@@ -95,7 +88,7 @@ export default {
}
.timeline-item:last-child::before {
display: none;
bottom: 0px;
}
.timeline-content {

View File

@@ -10,7 +10,7 @@
发布评论
<span class="shortcut-icon" v-if="!isMobile"> {{ isMac ? '' : 'Ctrl' }} </span>
</template>
<template v-else> <loading-four /> 发布中... </template>
<template v-else> <loading-four class="loading-icon" /> 发布中... </template>
</div>
</div>
</div>

View File

@@ -9,7 +9,7 @@
发送
<span class="shortcut-icon" v-if="!isMobile"> {{ isMac ? '' : 'Ctrl' }} </span>
</template>
<template v-else> <loading-four /> 发送中... </template>
<template v-else> <loading-four class="loading-icon" /> 发送中... </template>
</div>
</div>
</div>

View File

@@ -0,0 +1,167 @@
<template>
<div class="timeline-container">
<div class="timeline-header">
<div class="timeline-title">{{ headerText }}</div>
<div class="timeline-date">{{ formattedDate }}</div>
</div>
<div class="comment-content" v-if="entries.length > 0">
<div class="comment-content-item" v-for="entry in entries" :key="entry.comment.id">
<div class="comment-content-item-main">
<comment-one class="comment-content-item-icon" />
<div class="comment-content-item-text">
<NuxtLink
:to="`/posts/${entry.comment.post.id}#comment-${entry.comment.id}`"
class="timeline-comment-link"
>
{{ stripContent(entry.comment.content) }}
</NuxtLink>
</div>
</div>
<div class="timeline-date">{{ formatEntryDate(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(() => {
if (Array.isArray(props.item.entries)) {
return props.item.entries
}
if (props.item.comment) {
return [
{
type: props.item.type,
comment: props.item.comment,
createdAt: props.item.createdAt,
},
]
}
return []
})
const formattedDate = computed(() => TimeManager.formatWithDay(props.item.createdAt))
const hasReplies = computed(() => entries.value.some((entry) => !!entry.comment.parentComment))
const hasComments = computed(() => entries.value.some((entry) => !entry.comment.parentComment))
const headerText = computed(() => {
const count = entries.value.length
if (count === 0) return ''
if (hasComments.value && hasReplies.value) {
return `发布了${count}条评论/回复`
}
if (hasReplies.value) {
return `发布了${count}条回复`
}
return `发布了${count}条评论`
})
const formatEntryDate = (date) => TimeManager.format(date)
const stripContent = (content) => stripMarkdownLength(content ?? '', 200)
const parentSnippet = (entry) =>
stripMarkdownLength(entry.comment.parentComment?.content ?? '', 200)
</script>
<style scoped>
.timeline-container {
display: flex;
flex-direction: column;
gap: 12px;
padding-top: 5px;
padding-bottom: 20px;
}
.timeline-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.timeline-title {
font-size: 16px;
font-weight: 600;
}
.timeline-date {
font-size: 12px;
color: var(--timeline-date-color, #888);
white-space: nowrap;
}
.comment-content {
display: flex;
flex-direction: column;
gap: 3px;
}
.comment-content-item {
display: flex;
flex-direction: row;
justify-content: space-between;
gap: 6px;
}
.comment-content-item:last-child {
border-bottom: none;
padding-bottom: 0;
}
.comment-content-item-main {
display: flex;
gap: 8px;
align-items: flex-start;
}
.comment-content-item-icon {
flex-shrink: 0;
width: 18px;
height: 18px;
margin-top: 2px;
}
.comment-content-item-text {
display: flex;
flex-direction: column;
gap: 4px;
}
.comment-content-item-prefix {
font-size: 14px;
color: var(--text-color);
}
.timeline-comment-link {
font-size: 14px;
color: var(--link-color);
text-decoration: underline;
}
.timeline-comment-link:hover {
color: var(--primary-color);
}
.timeline-link {
color: var(--link-color);
text-decoration: none;
}
.timeline-link:hover {
text-decoration: underline;
}
@media (max-width: 768px) {
.comment-content-item-prefix,
.timeline-comment-link {
font-size: 13px;
}
}
</style>

View File

@@ -0,0 +1,147 @@
<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">
{{ item.post?.title }}
</NuxtLink>
<div class="timeline-snippet">
{{ strippedSnippet }}
</div>
<div class="article-meta" v-if="hasMeta">
<ArticleCategory v-if="item.post?.category" :category="item.post.category" />
<ArticleTags :tags="item.post?.tags" />
<div class="article-comment-count" v-if="item.post?.commentCount !== undefined">
<comment-one class="article-comment-count-icon" />
<span>{{ item.post?.commentCount }}</span>
</div>
</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 postLink = computed(() => {
const id = props.item.post?.id
return id ? `/posts/${id}` : '#'
})
const formattedDate = computed(() => TimeManager.format(props.item.createdAt))
const strippedSnippet = computed(() => stripMarkdown(props.item.post?.snippet ?? ''))
const hasMeta = computed(() => {
const tags = props.item.post?.tags ?? []
const hasTags = Array.isArray(tags) && tags.length > 0
const hasCategory = !!props.item.post?.category
const hasCommentCount =
props.item.post?.commentCount !== undefined && props.item.post?.commentCount !== null
return hasTags || hasCategory || hasCommentCount
})
</script>
<style scoped>
.timeline-container {
display: flex;
flex-direction: column;
padding-top: 5px;
gap: 12px;
border-radius: 10px;
background: var(--timeline-card-background, transparent);
}
.timeline-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.timeline-title {
font-size: 16px;
font-weight: 600;
}
.timeline-date {
font-size: 12px;
color: var(--timeline-date-color, #888);
}
.article-container {
display: flex;
flex-direction: column;
border: 1px solid var(--normal-border-color);
border-radius: 10px;
padding: 10px;
gap: 6px;
}
.timeline-article-link {
font-size: 18px;
font-weight: 600;
color: var(--link-color);
text-decoration: none;
}
.timeline-article-link:hover {
text-decoration: underline;
}
.timeline-snippet {
color: var(--timeline-snippet-color, #666);
font-size: 14px;
line-height: 1.6;
}
.article-meta {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.article-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.article-tag {
background-color: var(--article-info-background-color);
border-radius: 6px;
padding: 2px 6px;
font-size: 12px;
color: var(--text-color);
}
.article-comment-count {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: var(--text-color);
}
.article-comment-count-icon {
width: 16px;
height: 16px;
}
@media (max-width: 768px) {
.timeline-article-link {
font-size: 16px;
}
.timeline-snippet {
font-size: 13px;
}
}
</style>

View File

@@ -0,0 +1,111 @@
<template>
<div class="timeline-tag-item">
<div class="tags-container">
<div class="tags-container-item">
<div class="timeline-tag-title">创建了标签</div>
<ArticleTags v-if="tag" :tags="[tag]" />
<span class="timeline-tag-count" v-if="tag?.count"> x{{ tag.count }}</span>
</div>
<div v-if="timelineDate" class="timeline-date">{{ timelineDate }}</div>
</div>
<div v-if="hasDescription" class="timeline-snippet">
{{ tag?.description }}
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import TimeManager from '~/utils/time'
const props = defineProps({
item: { type: Object, required: true },
})
const emit = defineEmits(['tag-click'])
const tag = computed(() => props.item?.tag ?? null)
const hasDescription = computed(() => {
const description = tag.value?.description
return !!description
})
const timelineDate = computed(() => {
const date = props.item?.createdAt ?? tag.value?.createdAt
return date ? TimeManager.format(date) : ''
})
const summaryDate = computed(() => {
const date = tag.value?.createdAt ?? props.item?.createdAt
return date ? TimeManager.format(date) : ''
})
const isClickable = computed(() => props.mode === 'summary' && !!tag.value)
const handleTagClick = () => {
if (!isClickable.value) return
emit('tag-click', tag.value)
}
</script>
<style scoped>
.timeline-tag-item {
display: flex;
flex-direction: column;
gap: 6px;
}
.tags-container {
display: flex;
flex-direction: row;
gap: 10px;
padding-top: 5px;
justify-content: space-between;
align-items: center;
}
.tags-container-item {
display: flex;
flex-direction: row;
gap: 5px;
align-items: center;
}
.timeline-tag-title {
font-size: 16px;
font-weight: 600;
}
.timeline-tag-count {
font-size: 12px;
}
.timeline-date {
font-size: 12px;
color: gray;
margin-top: 5px;
white-space: nowrap;
}
.timeline-snippet {
font-size: 14px;
color: #666;
margin-top: 5px;
}
.timeline-link {
font-weight: bold;
color: var(--primary-color);
text-decoration: none;
word-break: break-word;
cursor: default;
}
.timeline-link.clickable {
cursor: pointer;
}
.timeline-link.clickable:hover {
text-decoration: underline;
}
</style>

View File

@@ -16,7 +16,7 @@
<div v-else class="login-page-button-primary disabled">
<div class="login-page-button-text">
<loading-four />
<loading-four class="loading-icon" />
登录中...
</div>
</div>

View File

@@ -30,7 +30,9 @@
>
发布
</div>
<div v-else class="post-submit-loading"><loading-four /> 发布中...</div>
<div v-else class="post-submit-loading">
<loading-four class="loading-icon" /> 发布中...
</div>
</div>
</div>
<LotteryForm v-if="postType === 'LOTTERY'" :data="lottery" />

View File

@@ -26,7 +26,9 @@
>
更新
</div>
<div v-else class="post-submit-loading"><loading-four /> 更新中...</div>
<div v-else class="post-submit-loading">
<loading-four class="loading-icon" /> 更新中...
</div>
</div>
</div>
</div>

View File

@@ -35,7 +35,7 @@
</div>
<div v-else class="signup-page-button-primary disabled">
<div class="signup-page-button-text">
<loading-four />
<loading-four class="loading-icon" />
发送中...
</div>
</div>
@@ -56,7 +56,7 @@
</div>
<div v-else class="signup-page-button-primary disabled">
<div class="signup-page-button-text">
<loading-four />
<loading-four class="loading-icon" />
验证中...
</div>
</div>

View File

@@ -112,19 +112,18 @@
{{ item.comment.post.title }}
</NuxtLink>
<template v-if="item.comment.parentComment">
下对
<NuxtLink
:to="`/posts/${item.comment.post.id}#comment-${item.comment.parentComment.id}`"
class="timeline-link"
class="timeline-comment-link"
>
{{ stripMarkdownLength(item.comment.parentComment.content, 200) }}
</NuxtLink>
回复了
<next class="reply-icon" /> 回复了
</template>
<template v-else> 下评论了 </template>
<NuxtLink
:to="`/posts/${item.comment.post.id}#comment-${item.comment.id}`"
class="timeline-link"
class="timeline-comment-link"
>
{{ stripMarkdownLength(item.comment.content, 200) }}
</NuxtLink>
@@ -143,15 +142,7 @@
<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>
<TimelinePostItem :item="item" />
</template>
</BaseTimeline>
</div>
@@ -164,15 +155,7 @@
<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>
<TimelineTagItem :item="item" />
</template>
</BaseTimeline>
</div>
@@ -213,56 +196,16 @@
<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>
<TimelinePostItem :item="item" />
</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>
<TimelineCommentGroup :item="item" />
</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>
<TimelineCommentGroup :item="item" />
</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>
<TimelineTagItem :item="item" />
</template>
</template>
</BaseTimeline>
@@ -326,6 +269,9 @@ import BasePlaceholder from '~/components/BasePlaceholder.vue'
import BaseTimeline from '~/components/BaseTimeline.vue'
import BaseTabs from '~/components/BaseTabs.vue'
import LevelProgress from '~/components/LevelProgress.vue'
import TimelineCommentGroup from '~/components/TimelineCommentGroup.vue'
import TimelinePostItem from '~/components/TimelinePostItem.vue'
import TimelineTagItem from '~/components/TimelineTagItem.vue'
import UserList from '~/components/UserList.vue'
import { toast } from '~/main'
import { authState, getToken } from '~/utils/auth'
@@ -415,7 +361,12 @@ const fetchSummary = async () => {
const postsRes = await fetch(`${API_BASE_URL}/api/users/${username}/hot-posts`)
if (postsRes.ok) {
const data = await postsRes.json()
hotPosts.value = data.map((p) => ({ icon: 'file-text', post: p }))
hotPosts.value = data.map((p) => ({
icon: 'file-text',
type: 'post',
post: p,
createdAt: p.createdAt,
}))
}
const repliesRes = await fetch(`${API_BASE_URL}/api/users/${username}/hot-replies`)
@@ -427,10 +378,66 @@ const fetchSummary = async () => {
const tagsRes = await fetch(`${API_BASE_URL}/api/users/${username}/hot-tags`)
if (tagsRes.ok) {
const data = await tagsRes.json()
hotTags.value = data.map((t) => ({ icon: 'tag-one', tag: t }))
hotTags.value = data.map((t) => ({
icon: 'tag-one',
type: 'tag',
tag: t,
createdAt: t.createdAt,
}))
}
}
const isDiscussionItem = (item) => item && (item.type === 'comment' || item.type === 'reply')
const toDateKey = (value) => {
const date = new Date(value)
if (Number.isNaN(date.getTime())) return ''
const month = `${date.getMonth() + 1}`.padStart(2, '0')
const day = `${date.getDate()}`.padStart(2, '0')
return `${date.getFullYear()}-${month}-${day}`
}
const combineDiscussionItems = (items) => {
const result = []
items.forEach((item) => {
if (!isDiscussionItem(item)) {
result.push(item)
return
}
const dateKey = toDateKey(item.createdAt)
const last = result[result.length - 1]
if (last && isDiscussionItem(last) && last.dateKey === dateKey) {
last.entries.push({
type: item.type,
comment: item.comment,
createdAt: item.createdAt,
})
if (item.type === 'comment' && last.type === 'reply') {
last.type = 'comment'
}
if (new Date(item.createdAt) > new Date(last.createdAt)) {
last.createdAt = item.createdAt
}
} else {
result.push({
type: item.type,
icon: item.icon,
createdAt: item.createdAt,
dateKey,
entries: [
{
type: item.type,
comment: item.comment,
createdAt: item.createdAt,
},
],
})
}
})
return result
}
const fetchTimeline = async () => {
const [postsRes, repliesRes, tagsRes] = await Promise.all([
fetch(`${API_BASE_URL}/api/users/${username}/posts?limit=50`),
@@ -461,7 +468,7 @@ const fetchTimeline = async () => {
})),
]
mapped.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
timelineItems.value = mapped
timelineItems.value = combineDiscussionItems(mapped)
}
const fetchFollowUsers = async () => {
@@ -662,6 +669,11 @@ watch(selectedTab, async (val) => {
color: #666;
}
.reply-icon {
color: var(--primary-color);
margin-left: 5px;
}
.profile-page-header-user-info-buttons {
display: flex;
flex-direction: row;
@@ -903,6 +915,7 @@ watch(selectedTab, async (val) => {
font-size: 12px;
color: gray;
margin-top: 5px;
white-space: nowrap;
}
.timeline-snippet {
@@ -913,8 +926,8 @@ watch(selectedTab, async (val) => {
.timeline-link {
font-weight: bold;
color: var(--primary-color);
text-decoration: none;
color: var(--text-color);
word-break: break-word;
}
@@ -939,6 +952,98 @@ watch(selectedTab, async (val) => {
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;
}
.tags-container {
display: flex;
flex-direction: row;
gap: 10px;
padding-top: 5px;
justify-content: space-between;
align-items: center;
}
.tags-container-item {
display: flex;
flex-direction: row;
gap: 5px;
align-items: center;
}
.timeline-tag-title {
font-size: 16px;
font-weight: 600;
}
.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;
margin-left: 5px;
}
.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 {
}

View File

@@ -22,6 +22,7 @@ import {
Moon,
ComputerOne,
Comment,
CommentOne,
Link,
SlyFaceWhitSmile,
Like,
@@ -103,6 +104,7 @@ export default defineNuxtPlugin((nuxtApp) => {
nuxtApp.vueApp.component('Moon', Moon)
nuxtApp.vueApp.component('ComputerOne', ComputerOne)
nuxtApp.vueApp.component('CommentIcon', Comment)
nuxtApp.vueApp.component('CommentOne', CommentOne)
nuxtApp.vueApp.component('LinkIcon', Link)
nuxtApp.vueApp.component('SlyFaceWhitSmile', SlyFaceWhitSmile)
nuxtApp.vueApp.component('Like', Like)

View File

@@ -40,4 +40,33 @@ export default class TimeManager {
return `${date.getFullYear()}.${month}.${day} ${timePart}`
}
// 仅显示日期(不含时间)
static formatWithDay(input) {
const date = new Date(input)
if (Number.isNaN(date.getTime())) return ''
const now = new Date()
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const startOfDate = new Date(date.getFullYear(), date.getMonth(), date.getDate())
const diffDays = Math.floor((startOfToday - startOfDate) / 86400000)
if (diffDays === 0) return '今天'
if (diffDays === 1) return '昨天'
if (diffDays === 2) return '前天'
const month = date.getMonth() + 1
const day = date.getDate()
if (date.getFullYear() === now.getFullYear()) {
return `${month}.${day}`
}
if (date.getFullYear() === now.getFullYear() - 1) {
return `去年 ${month}.${day}`
}
return `${date.getFullYear()}.${month}.${day}`
}
}