Compare commits

..

102 Commits

Author SHA1 Message Date
Tim
4380a988f7 chore: split large vite chunks 2025-08-15 13:10:47 +08:00
tim
2899f7af48 Merge branch 'main' of github.com:nagisa77/OpenIsle 2025-08-15 13:04:48 +08:00
tim
d4b05256a3 fix: update package-lock 2025-08-15 13:03:43 +08:00
Tim
57a26e375d Merge pull request #579 from palmcivet/docs/update-readme
feat: 更新 README “开发”章节
2025-08-15 12:57:05 +08:00
Palm Civet
8a202c4fba feat: 更新 README 2025-08-15 12:39:51 +08:00
Tim
089b2a3f5f Merge pull request #578 from AnNingUI/main
feat: Add Messages Update
2025-08-15 12:26:40 +08:00
AnNingUI
0b3d7a21d5 fix: 迁移markAllRead函数 2025-08-15 11:59:29 +08:00
AnNingUI
fe8a705a28 Merge branch 'main' of github.com:AnNingUI/OpenIsle 2025-08-15 11:44:19 +08:00
AnNingUI
974c7ba83e feat: Add Message Update 2025-08-15 11:42:39 +08:00
Tim
f2937d735d Merge pull request #576 from nagisa77/feature/ui_fix_v0
fix: 移动端才显示
2025-08-15 11:40:21 +08:00
Tim
423248c574 fix: 移动端才显示 2025-08-15 11:39:47 +08:00
Tim
5126cfda8c Merge pull request #575 from nagisa77/feature/ui_fix_v0
fix: 仅仅在主页显示
2025-08-15 11:38:07 +08:00
Tim
e009875797 fix: 仅仅在主页显示 2025-08-15 11:37:30 +08:00
Tim
04ff17f796 Merge pull request #574 from nagisa77/feature/ui_fix_v0
fix: ui fix
2025-08-15 11:25:45 +08:00
Tim
e9c9fbd742 fix: ui fix 2025-08-15 11:24:01 +08:00
Tim
b385945c2d Merge pull request #572 from CH-122/refactor/ui
refactor: 在 header 组件中添加发帖功能,移动端添加发帖悬浮按钮,优化首页搜索标题样式 ,
2025-08-15 11:16:31 +08:00
CH-122
24cbed2eda feat: 移动端添加发帖悬浮按钮 2025-08-15 10:59:29 +08:00
CH-122
ba073b71a6 feat: 在头部组件和菜单组件中添加发帖功能,并优化首页搜索标题样式 2025-08-15 10:37:51 +08:00
CH-122
5ff098ea21 feat: 添加 Tooltip 组件 2025-08-15 10:31:53 +08:00
Tim
f6713b956e Merge pull request #569 from immortal521/fix/564-theme-toggle-btn-position 2025-08-15 09:27:55 +08:00
Tim
b8ea12646f Merge pull request #568 from immortal521/fix/about-page-link-color-#566 2025-08-15 09:27:14 +08:00
immortal521
e573e54c2b fix: correct theme toggle button position (#564) 2025-08-15 03:00:57 +08:00
immortal521
8ec005d392 fix(about): fix link color issue on about page (#566)
Questions:
- Why are markdown styles split into `about-content` and
`info-content-text`?
- Why is `about-content` defined both globally and inside the Vue
component?
2025-08-15 02:42:04 +08:00
tim
b1f92f61a6 Merge branch 'main' of github.com:nagisa77/OpenIsle 2025-08-15 01:37:01 +08:00
tim
824b4dd8aa feat: ui update 2025-08-15 01:36:50 +08:00
Tim
6b08db7e58 Merge pull request #565 from nagisa77/feature/daily_bugfix_0814
fix: revert vditor change
2025-08-15 00:51:09 +08:00
tim
6f3830b3f7 fix: revert vditor change 2025-08-15 00:50:44 +08:00
Tim
d70dad723f Merge pull request #563 from nagisa77/feature/daily_bugfix_0814
若干问题修复,见评论
2025-08-15 00:31:46 +08:00
tim
2cf89e4802 fix: ssr 水合采用useAsyncData 2025-08-15 00:12:06 +08:00
tim
1fc6460ae0 fix: 修复vditor移动端贴顶的问题 2025-08-15 00:01:18 +08:00
Tim
a04e5c2f6f Merge pull request #560 from CH-122/feat/password-recovery-hint
feat: 忘记密码页面添加提示 & 修复缺少定义导致的报错 #535
2025-08-14 23:43:26 +08:00
Tim
77b26937f5 Merge pull request #562 from CH-122/fix/mobile-header-search
fix: 移动端 header 点击搜索图标功能异常
2025-08-14 23:39:19 +08:00
Tim
a1134b9d4b Merge pull request #559 from AnNingUI/main 2025-08-14 21:42:32 +08:00
AnNingUI
600f6ac1d1 fix: 修复代码高亮背景与抽奖背景色公用的问题 2025-08-14 21:39:39 +08:00
CH_122
9ad50b35c9 fix: 移动端 header 点击搜索图标功能异常 2025-08-14 21:35:57 +08:00
CH_122
867ee3907b feat: 忘记密码添加提示 & 修复缺少定义导致的报错 2025-08-14 21:21:34 +08:00
CH_122
58fcd42745 style: add cursor pointer to dropdown items for better UX 2025-08-14 21:20:23 +08:00
AnNingUI
0ee62a3a04 fix: 让代码展示背景的样式更加现代化,修复分类选择框仅有一个当前分类的问题
Fixes #558
2025-08-14 21:05:08 +08:00
Tim
f0bc7a22a0 fix: google login 问题修复 2025-08-14 20:34:21 +08:00
Tim
f6c0c8e226 Merge branch 'main' of github.com:nagisa77/OpenIsle 2025-08-14 20:25:33 +08:00
Tim
8f3c0d6710 fix: google login 问题修复 2025-08-14 20:25:09 +08:00
Tim
4f738778db Merge pull request #557 from nagisa77/feature/code_buauty
fix: 代码风格设置
2025-08-14 20:17:23 +08:00
Tim
84b45f785d fix: 代码风格设置 2025-08-14 19:55:53 +08:00
tim
df56d7e885 Revert "optimize(backend): optimize /api/posts/latest-reply"
This reverts commit 1e87e9252d.
2025-08-14 18:54:12 +08:00
tim
76176e135c Merge branch 'main' of github.com:nagisa77/OpenIsle 2025-08-14 18:27:25 +08:00
tim
ab87e0e51c fix: fix missing setup 2025-08-14 18:27:12 +08:00
Tim
5346a063bf Merge pull request #555 from netcaty/main
优化主页列表接口/api/posts/latest-reply
2025-08-14 18:19:19 +08:00
netcaty
e53f2130b8 Merge branch 'nagisa77:main' into main 2025-08-14 17:54:08 +08:00
netcat
1e87e9252d optimize(backend): optimize /api/posts/latest-reply
resolves #554
2025-08-14 17:53:01 +08:00
tim
3fc4d29dce Merge branch 'main' of github.com:nagisa77/OpenIsle 2025-08-14 17:27:42 +08:00
tim
bcdac9d9b2 fix: delete hook update 2025-08-14 17:27:30 +08:00
Tim
ea9710d16f Merge pull request #553 from nagisa77/codex/fix-missing-comment-pinning-feature
fix: restore comment pin handling
2025-08-14 17:21:26 +08:00
Tim
47134cadc2 fix: handle pinned comments from backend 2025-08-14 17:21:08 +08:00
tim
1a1b20b9cf fix: update css import 2025-08-14 17:20:02 +08:00
Tim
b63ebb8fae Merge pull request #552 from immortal521/feat/code-block-line-number
feat: add code block line number display
2025-08-14 16:47:46 +08:00
immortal521
e0f7299a86 feat: add code block line number display
- Added Maple Mono font
- Changed code block font to Maple Mono
- Increased mobile line height from 1.1 to 1.5
2025-08-14 15:40:14 +08:00
Tim
1f9ae8d057 Merge pull request #550 from nagisa77/feature/fix_db_error
fix: fix reward db error
2025-08-14 15:21:31 +08:00
Tim
da1ad73cf6 fix: fix reward db error 2025-08-14 15:19:21 +08:00
Tim
53c603f33a Merge pull request #546 from netcaty/main
optimize(backend): batch query for /api/categories && /api/tags
2025-08-14 14:30:14 +08:00
Tim
06f86f2b21 Merge pull request #545 from nagisa77/feature/first_screen
Feature/first screen
2025-08-14 14:26:17 +08:00
Tim
22693bfdd9 fix: 首屏ssr优化 2025-08-14 14:25:38 +08:00
netcat
0058f20b1e optimize(backend): batch query for /api/categories && /api/tags 2025-08-14 14:19:04 +08:00
Tim
304d941d68 Revert "fix: use home path"
This reverts commit 2efe4e733a.
2025-08-14 13:50:58 +08:00
Tim
3dbcd2ac4d Merge pull request #543 from nagisa77/feature/first_screen
fix: use home path
2025-08-14 13:46:48 +08:00
Tim
2efe4e733a fix: use home path 2025-08-14 13:45:29 +08:00
Tim
08239a16b8 Merge pull request #542 from nagisa77/feature/first_screen
fix: 首屏ssr优化
2025-08-14 13:40:07 +08:00
Tim
cb49dc9b73 fix: 首屏ssr优化 2025-08-14 13:39:25 +08:00
Tim
43d4c9be43 Merge pull request #541 from nagisa77/feature/first_screen
fix: 首屏ssr优化
2025-08-14 13:24:17 +08:00
Tim
1dc13698ad fix: 首屏ssr优化 2025-08-14 13:22:53 +08:00
Tim
d58432dcd9 Merge pull request #540 from nagisa77/codex/fix-logo-click-triggering-window.reload 2025-08-14 12:47:43 +08:00
Tim
e7ff73c7f9 fix: prevent header logo from triggering page reload 2025-08-14 12:47:26 +08:00
Tim
4ee9532d5f Merge pull request #539 from nagisa77/codex/fix-logo-click-reload-issue 2025-08-14 12:38:11 +08:00
Tim
80c3fd8ea2 fix: prevent homepage reload on logo click 2025-08-14 12:37:54 +08:00
Tim
7e277d06d5 Merge pull request #538 from nagisa77/feature/first_screen
fix: 首屏幕ssr优化
2025-08-14 12:29:58 +08:00
Tim
d2b68119bd fix: 首屏幕ssr优化 2025-08-14 12:29:08 +08:00
Tim
f7b0d7edd5 Merge pull request #537 from nagisa77/feature/first_screen
fix: 首屏幕ssr优化
2025-08-14 11:56:26 +08:00
Tim
cdea1ab911 fix: 首屏幕ssr优化 2025-08-14 11:55:39 +08:00
Tim
ada6bfb5cf Merge pull request #536 from nagisa77/codex/add-logo-click-to-refresh-homepage
feat: refresh home when clicking header logo
2025-08-14 11:00:37 +08:00
Tim
928dbd73b5 feat: allow logo to refresh home page 2025-08-14 11:00:17 +08:00
Tim
8c1a7afc6e Merge pull request #530 from nagisa77/feature/env
fix: 前后端代码域名hardcode调整(for预发环境做准备)
2025-08-14 10:38:49 +08:00
Tim
87453f7198 fix: add .env.example 2025-08-14 10:36:02 +08:00
Tim
48e3593ef9 Merge remote-tracking branch 'origin/main' into feature/env 2025-08-14 10:34:10 +08:00
Tim
655e8f2a65 fix: setup 迁移完成 v1 2025-08-14 10:27:01 +08:00
Tim
7a0afedc7c Merge pull request #533 from CH-122/feat/link 2025-08-13 18:12:34 +08:00
Tim
902fce5174 fix: setup 迁移完成 2025-08-13 17:59:38 +08:00
Tim
0034839e8d fix: 迁移部分页面为setup 2025-08-13 17:49:51 +08:00
CH-122
148fd36fd1 Merge branch 'main' into feat/link 2025-08-13 17:48:23 +08:00
Tim
06cd663eaf Merge pull request #532 from nagisa77/codex/add-comment-pinning-feature
feat: support comment pinning
2025-08-13 16:31:12 +08:00
Tim
0edbeabac2 feat: allow post authors to pin comments 2025-08-13 16:30:48 +08:00
Tim
65cc3ee58b Merge pull request #531 from nagisa77/codex/add-post-lottery-notification-to-author 2025-08-13 16:20:09 +08:00
Tim
6965fcfb7f feat: notify lottery author 2025-08-13 16:19:53 +08:00
Tim
40520c30ec Merge pull request #529 from nagisa77/codex/refactor-to-use-environment-variables
feat: move API and OAuth IDs to runtime config
2025-08-13 16:01:07 +08:00
Tim
5d7ca3d29a feat: use runtime config for API and OAuth client IDs 2025-08-13 16:00:26 +08:00
Tim
a3aec1133b Merge pull request #528 from nagisa77/codex/add-new-prize-notification-type
feat: add lottery win notification
2025-08-13 15:58:33 +08:00
Tim
8fa715477b feat: add lottery win notification 2025-08-13 15:57:59 +08:00
CH-122
9209ebea4c feat: 添加链接插件以支持外部链接在新窗口打开 2025-08-13 15:40:40 +08:00
Tim
47a9ce5843 fix: 后端取消网址hardcode 2025-08-13 14:02:32 +08:00
Tim
dfef13e2be Merge pull request #520 from AnNingUI/main
fix: 清理掉了大部分warn,优化了在移动端侧边栏的逻辑问题
2025-08-12 21:45:46 +08:00
AnNingUI
2f4d6e68da fix: 用传递menuBtn的ref代替手动查询dom的方式 2025-08-12 21:26:24 +08:00
AnNingUI
414872f61e fix: 解决tag与类别切换需要reload整个页面的bug 2025-08-12 20:42:31 +08:00
AnNingUI
82475f71db fix: 清理掉了所有warn,优化了在移动端侧边栏的逻辑问题 2025-08-12 20:36:00 +08:00
Tim
a6874e9be3 Merge pull request #512 from nagisa77/feature/message_control
feat: message control
2025-08-12 17:45:17 +08:00
97 changed files with 7775 additions and 5571 deletions

View File

@@ -10,7 +10,7 @@
OpenIsle 是一个使用 Spring Boot 和 Vue 3 构建的全栈开源社区平台,提供用户注册、登录、贴文发布、评论交互等完整功能,可用于项目社区或直接打造自主社区站点。
## 🚀 部署
## 🚧 开发
### 后端
@@ -20,9 +20,26 @@ OpenIsle 是一个使用 Spring Boot 和 Vue 3 构建的全栈开源社区平台
### 前端
1. `cd open-isle-cli`
2. 执行 `npm install`
3. `npm run serve`可在本地启动开发服务,产品环境使用 `npm run build`生成 `dist/` 文件,配合线上网站方式部署
1. 进入前端目录
```bash
cd frontend_nuxt
```
2. 安装依赖
```bash
npm install
```
3. 启动开发服务
```bash
npm run dev
```
生产版本使用如下命令编译:
```bash
npm run build
```
会在 `.output` 目录生成文件,配合线上网站方式部署
## ✨ 项目特点

View File

@@ -41,7 +41,7 @@ public class SecurityConfig {
private final UserRepository userRepository;
private final AccessDeniedHandler customAccessDeniedHandler;
private final UserVisitService userVisitService;
@Value("${app.website-url:https://www.open-isle.com}")
@Value("${app.website-url}")
private String websiteUrl;
@Bean
@@ -81,8 +81,8 @@ public class SecurityConfig {
"http://localhost",
"http://30.211.97.238:3000",
"http://30.211.97.238",
"http://192.168.7.70",
"http://192.168.7.70:8080",
"http://192.168.7.98",
"http://192.168.7.98:3000",
websiteUrl,
websiteUrl.replace("://www.", "://")
));

View File

@@ -0,0 +1,29 @@
package com.openisle.controller;
import com.openisle.dto.CommentDto;
import com.openisle.mapper.CommentMapper;
import com.openisle.service.CommentService;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
/**
* Endpoints for administrators to manage comments.
*/
@RestController
@RequestMapping("/api/admin/comments")
@RequiredArgsConstructor
public class AdminCommentController {
private final CommentService commentService;
private final CommentMapper commentMapper;
@PostMapping("/{id}/pin")
public CommentDto pin(@PathVariable Long id, Authentication auth) {
return commentMapper.toDto(commentService.pinComment(auth.getName(), id));
}
@PostMapping("/{id}/unpin")
public CommentDto unpin(@PathVariable Long id, Authentication auth) {
return commentMapper.toDto(commentService.unpinComment(auth.getName(), id));
}
}

View File

@@ -18,7 +18,7 @@ public class AdminUserController {
private final UserRepository userRepository;
private final NotificationRepository notificationRepository;
private final EmailSender emailSender;
@Value("${app.website-url:https://www.open-isle.com}")
@Value("${app.website-url}")
private String websiteUrl;
@PostMapping("/{id}/approve")

View File

@@ -12,6 +12,7 @@ import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
@RestController
@@ -44,8 +45,11 @@ public class CategoryController {
@GetMapping
public List<CategoryDto> list() {
return categoryService.listCategories().stream()
.map(c -> categoryMapper.toDto(c, postService.countPostsByCategory(c.getId())))
List<Category> all = categoryService.listCategories();
List<Long> ids = all.stream().map(Category::getId).toList();
Map<Long, Long> postsCntByCategoryIds = postService.countPostsByCategoryIds(ids);
return all.stream()
.map(c -> categoryMapper.toDto(c, postsCntByCategoryIds.getOrDefault(c.getId(), 0L)))
.sorted((a, b) -> Long.compare(b.getCount(), a.getCount()))
.collect(Collectors.toList());
}

View File

@@ -85,4 +85,16 @@ public class CommentController {
commentService.deleteComment(auth.getName(), id);
log.debug("deleteComment completed for comment {}", id);
}
@PostMapping("/comments/{id}/pin")
public CommentDto pinComment(@PathVariable Long id, Authentication auth) {
log.debug("pinComment called by user {} for comment {}", auth.getName(), id);
return commentMapper.toDto(commentService.pinComment(auth.getName(), id));
}
@PostMapping("/comments/{id}/unpin")
public CommentDto unpinComment(@PathVariable Long id, Authentication auth) {
log.debug("unpinComment called by user {} for comment {}", auth.getName(), id);
return commentMapper.toDto(commentService.unpinComment(auth.getName(), id));
}
}

View File

@@ -22,7 +22,7 @@ import java.util.List;
public class SitemapController {
private final PostRepository postRepository;
@Value("${app.website-url:https://www.open-isle.com}")
@Value("${app.website-url}")
private String websiteUrl;
@GetMapping(value = "/sitemap.xml", produces = MediaType.APPLICATION_XML_VALUE)

View File

@@ -15,6 +15,7 @@ import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
@RestController
@@ -62,8 +63,11 @@ public class TagController {
@GetMapping
public List<TagDto> list(@RequestParam(value = "keyword", required = false) String keyword,
@RequestParam(value = "limit", required = false) Integer limit) {
List<TagDto> dtos = tagService.searchTags(keyword).stream()
.map(t -> tagMapper.toDto(t, postService.countPostsByTag(t.getId())))
List<Tag> tags = tagService.searchTags(keyword);
List<Long> tagIds = tags.stream().map(Tag::getId).toList();
Map<Long, Long> postCntByTagIds = postService.countPostsByTagIds(tagIds);
List<TagDto> dtos = tags.stream()
.map(t -> tagMapper.toDto(t, postCntByTagIds.getOrDefault(t.getId(), 0L)))
.sorted((a, b) -> Long.compare(b.getCount(), a.getCount()))
.collect(Collectors.toList());
if (limit != null && limit > 0 && dtos.size() > limit) {

View File

@@ -13,6 +13,7 @@ public class CommentDto {
private Long id;
private String content;
private LocalDateTime createdAt;
private LocalDateTime pinnedAt;
private AuthorDto author;
private List<CommentDto> replies;
private List<ReactionDto> reactions;

View File

@@ -24,6 +24,7 @@ public class CommentMapper {
dto.setId(comment.getId());
dto.setContent(comment.getContent());
dto.setCreatedAt(comment.getCreatedAt());
dto.setPinnedAt(comment.getPinnedAt());
dto.setAuthor(userMapper.toAuthorDto(comment.getAuthor()));
dto.setReward(0);
return dto;

View File

@@ -38,4 +38,7 @@ public class Comment {
@JoinColumn(name = "parent_id")
private Comment parent;
@Column
private LocalDateTime pinnedAt;
}

View File

@@ -22,7 +22,7 @@ public class Notification {
private Long id;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
@Column(nullable = false, length = 50)
private NotificationType type;
@ManyToOne(fetch = FetchType.LAZY, optional = false)

View File

@@ -32,6 +32,10 @@ public enum NotificationType {
REGISTER_REQUEST,
/** A user redeemed an activity reward */
ACTIVITY_REDEEM,
/** You won a lottery post */
LOTTERY_WIN,
/** Your lottery post was drawn */
LOTTERY_DRAW,
/** You were mentioned in a post or comment */
MENTION
}

View File

@@ -92,8 +92,14 @@ public interface PostRepository extends JpaRepository<Post, Long> {
long countByCategory_Id(Long categoryId);
@Query("SELECT c.id, COUNT(p) FROM Post p JOIN p.category c WHERE c.id IN :categoryIds GROUP BY c.id")
List<Object[]> countPostsByCategoryIds(@Param("categoryIds") List<Long> categoryIds);
long countDistinctByTags_Id(Long tagId);
@Query("SELECT t.id, COUNT(DISTINCT p) FROM Post p JOIN p.tags t WHERE t.id IN :tagIds GROUP BY t.id")
List<Object[]> countPostsByTagIds(@Param("tagIds") List<Long> tagIds);
long countByAuthor_Id(Long userId);
@Query("SELECT FUNCTION('date', p.createdAt) AS d, COUNT(p) AS c FROM Post p " +

View File

@@ -23,6 +23,7 @@ import java.util.List;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
@Service
@RequiredArgsConstructor
@@ -129,13 +130,26 @@ public class CommentService {
Post post = postRepository.findById(postId)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found"));
List<Comment> list = commentRepository.findByPostAndParentIsNullOrderByCreatedAtAsc(post);
if (sort == CommentSort.NEWEST) {
list.sort(java.util.Comparator.comparing(Comment::getCreatedAt).reversed());
} else if (sort == CommentSort.MOST_INTERACTIONS) {
list.sort((a, b) -> Integer.compare(interactionCount(b), interactionCount(a)));
java.util.List<Comment> pinned = new java.util.ArrayList<>();
java.util.List<Comment> others = new java.util.ArrayList<>();
for (Comment c : list) {
if (c.getPinnedAt() != null) {
pinned.add(c);
} else {
others.add(c);
}
}
log.debug("getCommentsForPost returning {} comments", list.size());
return list;
pinned.sort(java.util.Comparator.comparing(Comment::getPinnedAt).reversed());
if (sort == CommentSort.NEWEST) {
others.sort(java.util.Comparator.comparing(Comment::getCreatedAt).reversed());
} else if (sort == CommentSort.MOST_INTERACTIONS) {
others.sort((a, b) -> Integer.compare(interactionCount(b), interactionCount(a)));
}
java.util.List<Comment> result = new java.util.ArrayList<>();
result.addAll(pinned);
result.addAll(others);
log.debug("getCommentsForPost returning {} comments", result.size());
return result;
}
public List<Comment> getReplies(Long parentId) {
@@ -223,6 +237,32 @@ public class CommentService {
log.debug("deleteCommentCascade removed comment {}", comment.getId());
}
@Transactional
public Comment pinComment(String username, Long id) {
Comment c = commentRepository.findById(id)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("Comment not found"));
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
if (!user.getId().equals(c.getPost().getAuthor().getId()) && user.getRole() != Role.ADMIN) {
throw new IllegalArgumentException("Unauthorized");
}
c.setPinnedAt(LocalDateTime.now());
return commentRepository.save(c);
}
@Transactional
public Comment unpinComment(String username, Long id) {
Comment c = commentRepository.findById(id)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("Comment not found"));
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
if (!user.getId().equals(c.getPost().getAuthor().getId()) && user.getRole() != Role.ADMIN) {
throw new IllegalArgumentException("Unauthorized");
}
c.setPinnedAt(null);
return commentRepository.save(c);
}
private int interactionCount(Comment comment) {
int reactions = reactionRepository.findByComment(comment).size();
int replies = commentRepository.findByParentOrderByCreatedAtAsc(comment).size();

View File

@@ -36,7 +36,7 @@ public class NotificationService {
private final ReactionRepository reactionRepository;
private final Executor notificationExecutor;
@Value("${app.website-url:https://www.open-isle.com}")
@Value("${app.website-url}")
private String websiteUrl;
private static final Pattern MENTION_PATTERN = Pattern.compile("@\\[([^\\]]+)\\]");

View File

@@ -31,16 +31,15 @@ import com.openisle.service.EmailSender;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.util.List;
import java.util.*;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.CollectionUtils;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.ScheduledFuture;
@@ -69,6 +68,8 @@ public class PostService {
private final EmailSender emailSender;
private final ApplicationContext applicationContext;
private final ConcurrentMap<Long, ScheduledFuture<?>> scheduledFinalizations = new ConcurrentHashMap<>();
@Value("${app.website-url:https://www.open-isle.com}")
private String websiteUrl;
@org.springframework.beans.factory.annotation.Autowired
public PostService(PostRepository postRepository,
@@ -249,6 +250,15 @@ public class PostService {
if (w.getEmail() != null) {
emailSender.sendEmail(w.getEmail(), "你中奖了", "恭喜你在抽奖贴 \"" + lp.getTitle() + "\" 中获奖");
}
notificationService.createNotification(w, NotificationType.LOTTERY_WIN, lp, null, null, lp.getAuthor(), null, null);
notificationService.sendCustomPush(w, "你中奖了", String.format("%s/posts/%d", websiteUrl, lp.getId()));
}
if (lp.getAuthor() != null) {
if (lp.getAuthor().getEmail() != null) {
emailSender.sendEmail(lp.getAuthor().getEmail(), "抽奖已开奖", "您的抽奖贴 \"" + lp.getTitle() + "\" 已开奖");
}
notificationService.createNotification(lp.getAuthor(), NotificationType.LOTTERY_DRAW, lp, null, null, null, null, null);
notificationService.sendCustomPush(lp.getAuthor(), "抽奖已开奖", String.format("%s/posts/%d", websiteUrl, lp.getId()));
}
});
}
@@ -556,10 +566,31 @@ public class PostService {
return postRepository.countByCategory_Id(categoryId);
}
public Map<Long, Long> countPostsByCategoryIds(List<Long> categoryIds) {
Map<Long, Long> result = new HashMap<>();
var dbResult = postRepository.countPostsByCategoryIds(categoryIds);
dbResult.forEach(r -> {
result.put(((Long)r[0]), ((Long)r[1]));
});
return result;
}
public long countPostsByTag(Long tagId) {
return postRepository.countDistinctByTags_Id(tagId);
}
public Map<Long, Long> countPostsByTagIds(List<Long> tagIds) {
Map<Long, Long> result = new HashMap<>();
if (CollectionUtils.isEmpty(tagIds)) {
return result;
}
var dbResult = postRepository.countPostsByTagIds(tagIds);
dbResult.forEach(r -> {
result.put(((Long)r[0]), ((Long)r[1]));
});
return result;
}
private java.util.List<Post> sortByPinnedAndCreated(java.util.List<Post> posts) {
return posts.stream()
.sorted(java.util.Comparator

View File

@@ -27,7 +27,7 @@ public class ReactionService {
private final NotificationService notificationService;
private final EmailSender emailSender;
@Value("${app.website-url:https://www.open-isle.com}")
@Value("${app.website-url}")
private String websiteUrl;
@Transactional

View File

@@ -0,0 +1 @@
ALTER TABLE comments ADD COLUMN pinned_at DATETIME(6) NULL;

View File

@@ -93,4 +93,50 @@ class PostServiceTest {
() -> service.createPost("alice", 1L, "t", "c", List.of(1L),
null, null, null, null, null, null));
}
@Test
void finalizeLotteryNotifiesAuthor() {
PostRepository postRepo = mock(PostRepository.class);
UserRepository userRepo = mock(UserRepository.class);
CategoryRepository catRepo = mock(CategoryRepository.class);
TagRepository tagRepo = mock(TagRepository.class);
LotteryPostRepository lotteryRepo = mock(LotteryPostRepository.class);
NotificationService notifService = mock(NotificationService.class);
SubscriptionService subService = mock(SubscriptionService.class);
CommentService commentService = mock(CommentService.class);
CommentRepository commentRepo = mock(CommentRepository.class);
ReactionRepository reactionRepo = mock(ReactionRepository.class);
PostSubscriptionRepository subRepo = mock(PostSubscriptionRepository.class);
NotificationRepository notificationRepo = mock(NotificationRepository.class);
PostReadService postReadService = mock(PostReadService.class);
ImageUploader imageUploader = mock(ImageUploader.class);
TaskScheduler taskScheduler = mock(TaskScheduler.class);
EmailSender emailSender = mock(EmailSender.class);
ApplicationContext context = mock(ApplicationContext.class);
PostService service = new PostService(postRepo, userRepo, catRepo, tagRepo, lotteryRepo,
notifService, subService, commentService, commentRepo,
reactionRepo, subRepo, notificationRepo, postReadService,
imageUploader, taskScheduler, emailSender, context, PublishMode.DIRECT);
when(context.getBean(PostService.class)).thenReturn(service);
User author = new User();
author.setId(1L);
User winner = new User();
winner.setId(2L);
LotteryPost lp = new LotteryPost();
lp.setId(1L);
lp.setAuthor(author);
lp.setTitle("L");
lp.setPrizeCount(1);
lp.getParticipants().add(winner);
when(lotteryRepo.findById(1L)).thenReturn(Optional.of(lp));
service.finalizeLottery(1L);
verify(notifService).createNotification(eq(winner), eq(NotificationType.LOTTERY_WIN), eq(lp), isNull(), isNull(), eq(author), isNull(), isNull());
verify(notifService).createNotification(eq(author), eq(NotificationType.LOTTERY_DRAW), eq(lp), isNull(), isNull(), isNull(), isNull(), isNull());
}
}

View File

@@ -0,0 +1,6 @@
NUXT_PUBLIC_API_BASE_URL=https://www.open-isle.com
NUXT_PUBLIC_WEBSITE_BASE_URL=https://www.open-isle.com
NUXT_PUBLIC_GOOGLE_CLIENT_ID=777830451304-nt8afkkap18gui4f9entcha99unal744.apps.googleusercontent.com
NUXT_PUBLIC_GITHUB_CLIENT_ID=Ov23liVkO1NPAX5JyWxJ
NUXT_PUBLIC_DISCORD_CLIENT_ID=1394985417044000779
NUXT_PUBLIC_TWITTER_CLIENT_ID=ZTRTU05KSk9KTTJrTTdrVC1tc1E6MTpjaQ

View File

@@ -2,3 +2,4 @@ node_modules
.nuxt
dist
.output
.env

View File

@@ -1,7 +1,11 @@
<template>
<div id="app">
<div class="header-container">
<HeaderComponent @toggle-menu="menuVisible = !menuVisible" :show-menu-btn="!hideMenu" />
<HeaderComponent
ref="header"
@toggle-menu="menuVisible = !menuVisible"
:show-menu-btn="!hideMenu"
/>
</div>
<div class="main-container">
@@ -11,53 +15,66 @@
<div class="content" :class="{ 'menu-open': menuVisible && !hideMenu }">
<NuxtPage keepalive />
</div>
<div v-if="showNewPostIcon && isMobile" class="new-post-icon" @click="goToNewPost">
<i class="fas fa-edit"></i>
</div>
</div>
<GlobalPopups />
</div>
</template>
<script>
<script setup>
import HeaderComponent from '~/components/HeaderComponent.vue'
import MenuComponent from '~/components/MenuComponent.vue'
import GlobalPopups from '~/components/GlobalPopups.vue'
import { useIsMobile } from '~/utils/screen'
export default {
name: 'App',
components: { HeaderComponent, MenuComponent, GlobalPopups },
setup() {
const isMobile = useIsMobile()
const menuVisible = ref(!isMobile.value)
const hideMenu = computed(() => {
return [
'/login',
'/signup',
'/404',
'/signup-reason',
'/github-callback',
'/twitter-callback',
'/discord-callback',
'/forgot-password',
'/google-callback',
].includes(useRoute().path)
})
const isMobile = useIsMobile()
const menuVisible = ref(!isMobile.value)
onMounted(() => {
if (typeof window !== 'undefined') {
menuVisible.value = window.innerWidth > 768
}
})
const showNewPostIcon = computed(() => useRoute().path === '/')
const handleMenuOutside = () => {
if (isMobile.value) menuVisible.value = false
}
const hideMenu = computed(() => {
return [
'/login',
'/signup',
'/404',
'/signup-reason',
'/github-callback',
'/twitter-callback',
'/discord-callback',
'/forgot-password',
'/google-callback',
].includes(useRoute().path)
})
return { menuVisible, hideMenu, handleMenuOutside }
},
const header = useTemplateRef('header')
onMounted(() => {
if (typeof window !== 'undefined') {
menuVisible.value = window.innerWidth > 768
}
})
const handleMenuOutside = (event) => {
const btn = header.value.$refs.menuBtn
if (btn && (btn === event.target || btn.contains(event.target))) {
return // 如果是菜单按钮的点击,不处理关闭
}
if (isMobile.value) {
menuVisible.value = false
}
}
const goToNewPost = () => {
navigateTo('/new-post', { replace: false })
}
</script>
<style src="~/assets/global.css"></style>
<style>
<style scoped>
.header-container {
position: fixed;
top: 0;
@@ -90,6 +107,24 @@ export default {
margin: 0 auto;
}
.new-post-icon {
background-color: var(--new-post-icon-color);
color: white;
width: 60px;
height: 60px;
border-radius: 50%;
position: fixed;
bottom: 40px;
right: 20px;
font-size: 20px;
cursor: pointer;
z-index: 1000;
display: flex;
backdrop-filter: blur(5px);
justify-content: center;
align-items: center;
}
@media (max-width: 768px) {
.content,
.content.menu-open {

View File

@@ -0,0 +1,143 @@
/* Maple Mono - Thin 100 */
@font-face {
font-family: "Maple Mono";
src: url("/fonts/maple-mono-100-normal.woff2") format("woff2");
font-weight: 100;
font-style: normal;
font-display: swap;
}
/* Maple Mono - Thin Italic 100 */
@font-face {
font-family: "Maple Mono";
src: url("/fonts/maple-mono-100-italic.woff2") format("woff2");
font-weight: 100;
font-style: italic;
font-display: swap;
}
/* Maple Mono - ExtraLight 200 */
@font-face {
font-family: "Maple Mono";
src: url("/fonts/maple-mono-200-normal.woff2") format("woff2");
font-weight: 200;
font-style: normal;
font-display: swap;
}
/* Maple Mono - ExtraLight Italic 200 */
@font-face {
font-family: "Maple Mono";
src: url("/fonts/maple-mono-200-italic.woff2") format("woff2");
font-weight: 200;
font-style: italic;
font-display: swap;
}
/* Maple Mono - Light 300 */
@font-face {
font-family: "Maple Mono";
src: url("/fonts/maple-mono-300-normal.woff2") format("woff2");
font-weight: 300;
font-style: normal;
font-display: swap;
}
/* Maple Mono - Light Italic 300 */
@font-face {
font-family: "Maple Mono";
src: url("/fonts/maple-mono-300-italic.woff2") format("woff2");
font-weight: 300;
font-style: italic;
font-display: swap;
}
/* Maple Mono - Regular 400 */
@font-face {
font-family: "Maple Mono";
src: url("/fonts/maple-mono-400-normal.woff2") format("woff2");
font-weight: 400;
font-style: normal;
font-display: swap;
}
/* Maple Mono - Italic 400 */
@font-face {
font-family: "Maple Mono";
src: url("/fonts/maple-mono-400-italic.woff2") format("woff2");
font-weight: 400;
font-style: italic;
font-display: swap;
}
/* Maple Mono - Medium 500 */
@font-face {
font-family: "Maple Mono";
src: url("/fonts/maple-mono-500-normal.woff2") format("woff2");
font-weight: 500;
font-style: normal;
font-display: swap;
}
/* Maple Mono - Medium Italic 500 */
@font-face {
font-family: "Maple Mono";
src: url("/fonts/maple-mono-500-italic.woff2") format("woff2");
font-weight: 500;
font-style: italic;
font-display: swap;
}
/* Maple Mono - SemiBold 600 */
@font-face {
font-family: "Maple Mono";
src: url("/fonts/maple-mono-600-normal.woff2") format("woff2");
font-weight: 600;
font-style: normal;
font-display: swap;
}
/* Maple Mono - SemiBold Italic 600 */
@font-face {
font-family: "Maple Mono";
src: url("/fonts/maple-mono-600-italic.woff2") format("woff2");
font-weight: 600;
font-style: italic;
font-display: swap;
}
/* Maple Mono - Bold 700 */
@font-face {
font-family: "Maple Mono";
src: url("/fonts/maple-mono-700-normal.woff2") format("woff2");
font-weight: 700;
font-style: normal;
font-display: swap;
}
/* Maple Mono - Bold Italic 700 */
@font-face {
font-family: "Maple Mono";
src: url("/fonts/maple-mono-700-italic.woff2") format("woff2");
font-weight: 700;
font-style: italic;
font-display: swap;
}
/* Maple Mono - ExtraBold 800 */
@font-face {
font-family: "Maple Mono";
src: url("/fonts/maple-mono-800-normal.woff2") format("woff2");
font-weight: 800;
font-style: normal;
font-display: swap;
}
/* Maple Mono - ExtraBold Italic 800 */
@font-face {
font-family: "Maple Mono";
src: url("/fonts/maple-mono-800-italic.woff2") format("woff2");
font-weight: 800;
font-style: italic;
font-display: swap;
}

View File

@@ -2,6 +2,7 @@
--primary-color-hover: rgb(9, 95, 105);
--primary-color: rgb(10, 110, 120);
--primary-color-disabled: rgba(93, 152, 156, 0.5);
--new-post-icon-color: rgba(10, 111, 120, 0.598);
--header-height: 60px;
--header-background-color: white;
--header-border-color: lightgray;
@@ -15,14 +16,16 @@
--menu-selected-background-color: rgba(208, 250, 255, 0.659);
--menu-text-color: black;
--scroller-background-color: rgba(130, 175, 180, 0.5);
--normal-background-color: rgb(241, 241, 241);
/* --normal-background-color: rgb(241, 241, 241); */
--normal-background-color: white;
--lottery-background-color: rgb(241, 241, 241);
--code-highlight-background-color: rgb(241, 241, 241);
--login-background-color: rgb(248, 248, 248);
--login-background-color-hover: #e0e0e0;
--text-color: black;
--blockquote-text-color: #6a737d;
--menu-width: 200px;
--page-max-width: 1200px;
--page-max-width: 1400px;
--page-max-width-mobile: 900px;
--article-info-background-color: #f0f0f0;
--activity-card-background-color: #fafafa;
@@ -40,10 +43,13 @@
--background-color-blur: var(--background-color);
--menu-border-color: #555;
--normal-border-color: #555;
--new-post-icon-color: rgba(10, 111, 120, 0.598);
--menu-selected-background-color: rgba(255, 255, 255, 0.1);
--menu-text-color: white;
--normal-background-color: #000000;
/* --normal-background-color: #000000; */
--normal-background-color: #333;
--lottery-background-color: #4e4e4e;
--code-highlight-background-color: #262b35;
--login-background-color: #575757;
--login-background-color-hover: #717171;
--text-color: #eee;
@@ -131,13 +137,43 @@ body {
}
.info-content-text pre {
background-color: var(--normal-background-color);
display: flex;
background-color: var(--code-highlight-background-color);
padding: 8px 12px;
border-radius: 4px;
line-height: 1.5;
position: relative;
}
.info-content-text pre .line-numbers {
counter-reset: line-number 0;
width: 2em;
font-size: 13px;
position: sticky;
flex-shrink: 0;
font-family: 'Maple Mono', monospace;
margin: 1em 0;
color: #888;
border-right: 1px solid #888;
box-sizing: border-box;
padding-right: 0.5em;
text-align: end;
}
.info-content-text pre .line-numbers .line-number::before {
content: counter(line-number);
counter-increment: line-number;
}
.info-content-text code {
font-family: 'Maple Mono', monospace;
font-size: 13px;
border-radius: 4px;
white-space: no-wrap;
background-color: var(--code-highlight-background-color);
color: var(--text-color);
}
.copy-code-btn {
position: absolute;
top: 4px;
@@ -156,20 +192,13 @@ body {
opacity: 1;
}
.info-content-text code {
font-family: 'Roboto Mono', monospace;
font-size: 13px;
border-radius: 4px;
white-space: pre-wrap;
background-color: var(--normal-background-color);
color: var(--text-color);
}
.about-content a,
.info-content-text a {
color: var(--primary-color);
text-decoration: none;
}
.about-content a:hover,
.info-content-text a:hover {
text-decoration: underline;
}
@@ -267,7 +296,7 @@ body {
}
.info-content-text pre {
line-height: 1.1;
line-height: 1.5;
}
.vditor-panel {

View File

@@ -37,8 +37,10 @@
<script setup>
import { computed } from 'vue'
import { API_BASE_URL, toast } from '~/main'
import { toast } from '~/main'
import { getToken } from '~/utils/auth'
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
const props = defineProps({
medals: {

View File

@@ -11,29 +11,20 @@
</BasePopup>
</template>
<script>
<script setup>
import BasePopup from '~/components/BasePopup.vue'
import { useRouter } from 'vue-router'
export default {
name: 'ActivityPopup',
components: { BasePopup },
props: {
visible: { type: Boolean, default: false },
icon: String,
text: String,
},
emits: ['close'],
setup(props, { emit }) {
const router = useRouter()
const gotoActivity = () => {
emit('close')
router.push('/activities')
}
const close = () => emit('close')
return { gotoActivity, close }
},
const props = defineProps({
visible: { type: Boolean, default: false },
icon: String,
text: String,
})
const emit = defineEmits(['close'])
const gotoActivity = async () => {
emit('close')
await navigateTo('/activities', { replace: true })
}
const close = () => emit('close')
</script>
<style scoped>

View File

@@ -12,25 +12,15 @@
</div>
</template>
<script>
import { useRouter } from 'vue-router'
<script setup>
const props = defineProps({
category: { type: Object, default: null },
})
export default {
name: 'ArticleCategory',
props: {
category: { type: Object, default: null },
},
setup(props) {
const router = useRouter()
const gotoCategory = () => {
if (!props.category) return
const value = encodeURIComponent(props.category.id ?? props.category.name)
router.push({ path: '/', query: { category: value } }).then(() => {
window.location.reload()
})
}
return { gotoCategory }
},
const gotoCategory = async () => {
if (!props.category) return
const value = encodeURIComponent(props.category.id ?? props.category.name)
await navigateTo({ path: '/', query: { category: value } }, { replace: true })
}
</script>

View File

@@ -17,24 +17,14 @@
</div>
</template>
<script>
import { useRouter } from 'vue-router'
<script setup>
defineProps({
tags: { type: Array, default: () => [] },
})
export default {
name: 'ArticleTags',
props: {
tags: { type: Array, default: () => [] },
},
setup() {
const router = useRouter()
const gotoTag = (tag) => {
const value = encodeURIComponent(tag.id ?? tag.name)
router.push({ path: '/', query: { tags: value } }).then(() => {
window.location.reload()
})
}
return { gotoTag }
},
const gotoTag = async (tag) => {
const value = encodeURIComponent(tag.id ?? tag.name)
await navigateTo({ path: '/', query: { tags: value } }, { replace: true })
}
</script>

View File

@@ -26,49 +26,43 @@
</Dropdown>
</template>
<script>
<script setup>
import { computed, ref, watch } from 'vue'
import { API_BASE_URL } from '~/main'
import Dropdown from '~/components/Dropdown.vue'
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
export default {
name: 'CategorySelect',
components: { Dropdown },
props: {
modelValue: { type: [String, Number], default: '' },
options: { type: Array, default: () => [] },
const props = defineProps({
modelValue: { type: [String, Number], default: '' },
options: { type: Array, default: () => [] },
})
const emit = defineEmits(['update:modelValue'])
const providedOptions = ref(Array.isArray(props.options) ? [...props.options] : [])
watch(
() => props.options,
(val) => {
providedOptions.value = Array.isArray(val) ? [...val] : []
},
emits: ['update:modelValue'],
setup(props, { emit }) {
const providedOptions = ref(Array.isArray(props.options) ? [...props.options] : [])
)
watch(
() => props.options,
(val) => {
providedOptions.value = Array.isArray(val) ? [...val] : []
},
)
const fetchCategories = async () => {
const res = await fetch(`${API_BASE_URL}/api/categories`)
if (!res.ok) return []
const data = await res.json()
return [{ id: '', name: '无分类' }, ...data]
}
const isImageIcon = (icon) => {
if (!icon) return false
return /^https?:\/\//.test(icon) || icon.startsWith('/')
}
const selected = computed({
get: () => props.modelValue,
set: (v) => emit('update:modelValue', v),
})
return { fetchCategories, selected, isImageIcon, providedOptions }
},
const fetchCategories = async () => {
const res = await fetch(`${API_BASE_URL}/api/categories`)
if (!res.ok) return []
const data = await res.json()
return [{ id: '', name: '无分类' }, ...data]
}
const isImageIcon = (icon) => {
if (!icon) return false
return /^https?:\/\//.test(icon) || icon.startsWith('/')
}
const selected = computed({
get: () => props.modelValue,
set: (v) => emit('update:modelValue', v),
})
</script>
<style scoped>

View File

@@ -22,6 +22,7 @@
:to="`/users/${comment.userId}?tab=achievements`"
>{{ getMedalTitle(comment.medal) }}</router-link
>
<i v-if="comment.pinned" class="fas fa-thumbtack pin-icon"></i>
<span v-if="level >= 2">
<i class="fas fa-reply reply-icon"></i>
<span class="user-name reply-user-name">{{ comment.parentUserName }}</span>
@@ -74,6 +75,7 @@
:comment="item"
:level="level + 1"
:default-show-replies="item.openReplies"
:post-author-id="postAuthorId"
/>
</template>
</BaseTimeline>
@@ -88,11 +90,10 @@
</div>
</template>
<script>
<script setup>
import { computed, ref, watch } from 'vue'
import VueEasyLightbox from 'vue-easy-lightbox'
import { useRouter } from 'vue-router'
import { API_BASE_URL, toast } from '~/main'
import { toast } from '~/main'
import { authState, getToken } from '~/utils/auth'
import { handleMarkdownClick, renderMarkdown } from '~/utils/markdown'
import { getMedalTitle } from '~/utils/medal'
@@ -100,214 +101,236 @@ import TimeManager from '~/utils/time'
import BaseTimeline from '~/components/BaseTimeline.vue'
import CommentEditor from '~/components/CommentEditor.vue'
import DropdownMenu from '~/components/DropdownMenu.vue'
import LoginOverlay from '~/components/LoginOverlay.vue'
import ReactionsGroup from '~/components/ReactionsGroup.vue'
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
const CommentItem = {
name: 'CommentItem',
emits: ['deleted'],
props: {
comment: {
type: Object,
required: true,
},
level: {
type: Number,
default: 0,
},
defaultShowReplies: {
type: Boolean,
default: false,
},
const props = defineProps({
comment: {
type: Object,
required: true,
},
setup(props, { emit }) {
const router = useRouter()
const showReplies = ref(props.level === 0 ? true : props.defaultShowReplies)
watch(
() => props.defaultShowReplies,
(val) => {
showReplies.value = props.level === 0 ? true : val
},
)
const showEditor = ref(false)
const editorWrapper = ref(null)
const isWaitingForReply = ref(false)
const lightboxVisible = ref(false)
const lightboxIndex = ref(0)
const lightboxImgs = ref([])
const loggedIn = computed(() => authState.loggedIn)
const countReplies = (list) => list.reduce((sum, r) => sum + 1 + countReplies(r.reply || []), 0)
const replyCount = computed(() => countReplies(props.comment.reply || []))
const toggleReplies = () => {
showReplies.value = !showReplies.value
}
const toggleEditor = () => {
showEditor.value = !showEditor.value
if (showEditor.value) {
setTimeout(() => {
editorWrapper.value?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
}, 100)
}
}
level: {
type: Number,
default: 0,
},
defaultShowReplies: {
type: Boolean,
default: false,
},
postAuthorId: {
type: [Number, String],
required: true,
},
})
// 合并所有子回复为一个扁平数组
const flattenReplies = (list) => {
let result = []
for (const r of list) {
result.push(r)
if (r.reply && r.reply.length > 0) {
result = result.concat(flattenReplies(r.reply))
}
}
return result
const emit = defineEmits(['deleted'])
const showReplies = ref(props.level === 0 ? true : props.defaultShowReplies)
watch(
() => props.defaultShowReplies,
(val) => {
showReplies.value = props.level === 0 ? true : val
},
)
const showEditor = ref(false)
const editorWrapper = ref(null)
const isWaitingForReply = ref(false)
const lightboxVisible = ref(false)
const lightboxIndex = ref(0)
const lightboxImgs = ref([])
const loggedIn = computed(() => authState.loggedIn)
const countReplies = (list) => list.reduce((sum, r) => sum + 1 + countReplies(r.reply || []), 0)
const replyCount = computed(() => countReplies(props.comment.reply || []))
const toggleReplies = () => {
showReplies.value = !showReplies.value
}
const toggleEditor = () => {
showEditor.value = !showEditor.value
if (showEditor.value) {
setTimeout(() => {
editorWrapper.value?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
}, 100)
}
}
const flattenReplies = (list) => {
let result = []
for (const r of list) {
result.push(r)
if (r.reply && r.reply.length > 0) {
result = result.concat(flattenReplies(r.reply))
}
}
return result
}
const replyList = computed(() => {
if (props.level < 1) {
return props.comment.reply
}
const replyList = computed(() => {
if (props.level < 1) {
return props.comment.reply
}
return flattenReplies(props.comment.reply || [])
return flattenReplies(props.comment.reply || [])
})
const isAuthor = computed(() => authState.username === props.comment.userName)
const isPostAuthor = computed(() => Number(authState.userId) === Number(props.postAuthorId))
const isAdmin = computed(() => authState.role === 'ADMIN')
const commentMenuItems = computed(() => {
const items = []
if (isAuthor.value || isAdmin.value) {
items.push({ text: '删除评论', color: 'red', onClick: () => deleteComment() })
}
if (isAdmin.value || isPostAuthor.value) {
if (props.comment.pinned) {
items.push({ text: '取消置顶', onClick: () => unpinComment() })
} else {
items.push({ text: '置顶', onClick: () => pinComment() })
}
}
return items
})
const deleteComment = async () => {
const token = getToken()
if (!token) {
toast.error('请先登录')
return
}
console.debug('Deleting comment', props.comment.id)
const res = await fetch(`${API_BASE_URL}/api/comments/${props.comment.id}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` },
})
console.debug('Delete comment response status', res.status)
if (res.ok) {
toast.success('已删除')
emit('deleted', props.comment.id)
} else {
toast.error('操作失败')
}
}
const submitReply = async (parentUserName, text, clear) => {
if (!text.trim()) return
isWaitingForReply.value = true
const token = getToken()
if (!token) {
toast.error('请先登录')
isWaitingForReply.value = false
return
}
console.debug('Submitting reply', { parentId: props.comment.id, text })
try {
const res = await fetch(`${API_BASE_URL}/api/comments/${props.comment.id}/replies`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({ content: text }),
})
const isAuthor = computed(() => authState.username === props.comment.userName)
const isAdmin = computed(() => authState.role === 'ADMIN')
const commentMenuItems = computed(() =>
isAuthor.value || isAdmin.value
? [{ text: '删除评论', color: 'red', onClick: () => deleteComment() }]
: [],
)
const deleteComment = async () => {
const token = getToken()
if (!token) {
toast.error('请先登录')
return
}
console.debug('Deleting comment', props.comment.id)
const res = await fetch(`${API_BASE_URL}/api/comments/${props.comment.id}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` },
console.debug('Submit reply response status', res.status)
if (res.ok) {
const data = await res.json()
console.debug('Submit reply response data', data)
const replyList = props.comment.reply || (props.comment.reply = [])
replyList.push({
id: data.id,
userName: data.author.username,
time: TimeManager.format(data.createdAt),
avatar: data.author.avatar,
medal: data.author.displayMedal,
text: data.content,
parentUserName: parentUserName,
reactions: [],
reply: (data.replies || []).map((r) => ({
id: r.id,
userName: r.author.username,
time: TimeManager.format(r.createdAt),
avatar: r.author.avatar,
text: r.content,
reactions: r.reactions || [],
reply: [],
openReplies: false,
src: r.author.avatar,
iconClick: () => navigateTo(`/users/${r.author.id}`),
})),
openReplies: false,
src: data.author.avatar,
iconClick: () => navigateTo(`/users/${data.author.id}`),
})
console.debug('Delete comment response status', res.status)
if (res.ok) {
toast.success('已删除')
emit('deleted', props.comment.id)
} else {
toast.error('操作失败')
}
clear()
showEditor.value = false
toast.success('回复成功')
} else if (res.status === 429) {
toast.error('回复过于频繁,请稍后再试')
} else {
toast.error(`回复失败: ${res.status} ${res.statusText}`)
}
const submitReply = async (parentUserName, text, clear) => {
if (!text.trim()) return
isWaitingForReply.value = true
const token = getToken()
if (!token) {
toast.error('请先登录')
isWaitingForReply.value = false
return
}
console.debug('Submitting reply', { parentId: props.comment.id, text })
try {
const res = await fetch(`${API_BASE_URL}/api/comments/${props.comment.id}/replies`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({ content: text }),
})
console.debug('Submit reply response status', res.status)
if (res.ok) {
const data = await res.json()
console.debug('Submit reply response data', data)
const replyList = props.comment.reply || (props.comment.reply = [])
replyList.push({
id: data.id,
userName: data.author.username,
time: TimeManager.format(data.createdAt),
avatar: data.author.avatar,
medal: data.author.displayMedal,
text: data.content,
parentUserName: parentUserName,
reactions: [],
reply: (data.replies || []).map((r) => ({
id: r.id,
userName: r.author.username,
time: TimeManager.format(r.createdAt),
avatar: r.author.avatar,
text: r.content,
reactions: r.reactions || [],
reply: [],
openReplies: false,
src: r.author.avatar,
iconClick: () => router.push(`/users/${r.author.id}`),
})),
openReplies: false,
src: data.author.avatar,
iconClick: () => router.push(`/users/${data.author.id}`),
})
clear()
showEditor.value = false
toast.success('回复成功')
} else if (res.status === 429) {
toast.error('回复过于频繁,请稍后再试')
} else {
toast.error(`回复失败: ${res.status} ${res.statusText}`)
}
} catch (e) {
console.debug('Submit reply error', e)
toast.error(`回复失败: ${e.message}`)
} finally {
isWaitingForReply.value = false
}
}
const copyCommentLink = () => {
const link = `${location.origin}${location.pathname}#comment-${props.comment.id}`
navigator.clipboard.writeText(link).then(() => {
toast.success('已复制')
})
}
const handleContentClick = (e) => {
handleMarkdownClick(e)
if (e.target.tagName === 'IMG') {
const container = e.target.parentNode
const imgs = [...container.querySelectorAll('img')].map((i) => i.src)
lightboxImgs.value = imgs
lightboxIndex.value = imgs.indexOf(e.target.src)
lightboxVisible.value = true
}
}
return {
showReplies,
toggleReplies,
showEditor,
toggleEditor,
submitReply,
copyCommentLink,
renderMarkdown,
isWaitingForReply,
commentMenuItems,
deleteComment,
lightboxVisible,
lightboxIndex,
lightboxImgs,
handleContentClick,
loggedIn,
replyCount,
replyList,
getMedalTitle,
editorWrapper,
}
},
} catch (e) {
console.debug('Submit reply error', e)
toast.error(`回复失败: ${e.message}`)
} finally {
isWaitingForReply.value = false
}
}
CommentItem.components = {
CommentItem,
CommentEditor,
BaseTimeline,
ReactionsGroup,
DropdownMenu,
VueEasyLightbox,
LoginOverlay,
const pinComment = async () => {
const token = getToken()
if (!token) {
toast.error('请先登录')
return
}
const url = isAdmin.value
? `${API_BASE_URL}/api/admin/comments/${props.comment.id}/pin`
: `${API_BASE_URL}/api/comments/${props.comment.id}/pin`
const res = await fetch(url, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
})
if (res.ok) {
props.comment.pinned = true
toast.success('已置顶')
} else {
toast.error('操作失败')
}
}
const unpinComment = async () => {
const token = getToken()
if (!token) {
toast.error('请先登录')
return
}
const url = isAdmin.value
? `${API_BASE_URL}/api/admin/comments/${props.comment.id}/unpin`
: `${API_BASE_URL}/api/comments/${props.comment.id}/unpin`
const res = await fetch(url, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
})
if (res.ok) {
props.comment.pinned = false
toast.success('已取消置顶')
} else {
toast.error('操作失败')
}
}
export default CommentItem
const copyCommentLink = () => {
const link = `${location.origin}${location.pathname}#comment-${props.comment.id}`
navigator.clipboard.writeText(link).then(() => {
toast.success('已复制')
})
}
const handleContentClick = (e) => {
handleMarkdownClick(e)
if (e.target.tagName === 'IMG') {
const container = e.target.parentNode
const imgs = [...container.querySelectorAll('img')].map((i) => i.src)
lightboxImgs.value = imgs
lightboxIndex.value = imgs.indexOf(e.target.src)
lightboxVisible.value = true
}
}
</script>
<style scoped>
@@ -370,6 +393,12 @@ export default CommentItem
margin-left: 10px;
}
.pin-icon {
font-size: 12px;
margin-left: 10px;
opacity: 0.6;
}
@keyframes highlight {
from {
background-color: yellow;

View File

@@ -82,6 +82,7 @@ export default {
.dropdown-item {
padding: 8px 16px;
white-space: nowrap;
cursor: pointer;
}
.dropdown-item:hover {
background-color: var(--menu-selected-background-color);

View File

@@ -11,95 +11,89 @@
</div>
</template>
<script>
<script setup>
import ActivityPopup from '~/components/ActivityPopup.vue'
import MedalPopup from '~/components/MedalPopup.vue'
import NotificationSettingPopup from '~/components/NotificationSettingPopup.vue'
import { API_BASE_URL } from '~/main'
import { authState } from '~/utils/auth'
export default {
name: 'GlobalPopups',
components: { ActivityPopup, MedalPopup, NotificationSettingPopup },
data() {
return {
showMilkTeaPopup: false,
milkTeaIcon: '',
showNotificationPopup: false,
showMedalPopup: false,
newMedals: [],
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
const showMilkTeaPopup = ref(false)
const milkTeaIcon = ref('')
const showNotificationPopup = ref(false)
const showMedalPopup = ref(false)
const newMedals = ref([])
onMounted(async () => {
await checkMilkTeaActivity()
if (showMilkTeaPopup.value) return
await checkNotificationSetting()
if (showNotificationPopup.value) return
await checkNewMedals()
})
const checkMilkTeaActivity = async () => {
if (!process.client) return
if (localStorage.getItem('milkTeaActivityPopupShown')) return
try {
const res = await fetch(`${API_BASE_URL}/api/activities`)
if (res.ok) {
const list = await res.json()
const a = list.find((i) => i.type === 'MILK_TEA' && !i.ended)
if (a) {
milkTeaIcon.value = a.icon
showMilkTeaPopup.value = true
}
}
},
async mounted() {
await this.checkMilkTeaActivity()
if (this.showMilkTeaPopup) return
await this.checkNotificationSetting()
if (this.showNotificationPopup) return
await this.checkNewMedals()
},
methods: {
async checkMilkTeaActivity() {
if (!process.client) return
if (localStorage.getItem('milkTeaActivityPopupShown')) return
try {
const res = await fetch(`${API_BASE_URL}/api/activities`)
if (res.ok) {
const list = await res.json()
const a = list.find((i) => i.type === 'MILK_TEA' && !i.ended)
if (a) {
this.milkTeaIcon = a.icon
this.showMilkTeaPopup = true
}
}
} catch (e) {
// ignore network errors
} catch (e) {
// ignore network errors
}
}
const closeMilkTeaPopup = () => {
if (!process.client) return
localStorage.setItem('milkTeaActivityPopupShown', 'true')
showMilkTeaPopup.value = false
checkNotificationSetting()
}
const checkNotificationSetting = async () => {
if (!process.client) return
if (!authState.loggedIn) return
if (localStorage.getItem('notificationSettingPopupShown')) return
showNotificationPopup.value = true
}
const closeNotificationPopup = () => {
if (!process.client) return
localStorage.setItem('notificationSettingPopupShown', 'true')
showNotificationPopup.value = false
checkNewMedals()
}
const checkNewMedals = async () => {
if (!process.client) return
if (!authState.loggedIn || !authState.userId) return
try {
const res = await fetch(`${API_BASE_URL}/api/medals?userId=${authState.userId}`)
if (res.ok) {
const medals = await res.json()
const seen = JSON.parse(localStorage.getItem('seenMedals') || '[]')
const m = medals.filter((i) => i.completed && !seen.includes(i.type))
if (m.length > 0) {
newMedals.value = m
showMedalPopup.value = true
}
},
closeMilkTeaPopup() {
if (!process.client) return
localStorage.setItem('milkTeaActivityPopupShown', 'true')
this.showMilkTeaPopup = false
this.checkNotificationSetting()
},
async checkNotificationSetting() {
if (!process.client) return
if (!authState.loggedIn) return
if (localStorage.getItem('notificationSettingPopupShown')) return
this.showNotificationPopup = true
},
closeNotificationPopup() {
if (!process.client) return
localStorage.setItem('notificationSettingPopupShown', 'true')
this.showNotificationPopup = false
this.checkNewMedals()
},
async checkNewMedals() {
if (!process.client) return
if (!authState.loggedIn || !authState.userId) return
try {
const res = await fetch(`${API_BASE_URL}/api/medals?userId=${authState.userId}`)
if (res.ok) {
const medals = await res.json()
const seen = JSON.parse(localStorage.getItem('seenMedals') || '[]')
const m = medals.filter((i) => i.completed && !seen.includes(i.type))
if (m.length > 0) {
this.newMedals = m
this.showMedalPopup = true
}
}
} catch (e) {
// ignore errors
}
},
closeMedalPopup() {
if (!process.client) return
const seen = new Set(JSON.parse(localStorage.getItem('seenMedals') || '[]'))
this.newMedals.forEach((m) => seen.add(m.type))
localStorage.setItem('seenMedals', JSON.stringify([...seen]))
this.showMedalPopup = false
},
},
}
} catch (e) {
// ignore errors
}
}
const closeMedalPopup = () => {
if (!process.client) return
const seen = new Set(JSON.parse(localStorage.getItem('seenMedals') || '[]'))
newMedals.value.forEach((m) => seen.add(m.type))
localStorage.setItem('seenMedals', JSON.stringify([...seen]))
showMedalPopup.value = false
}
</script>

View File

@@ -3,12 +3,12 @@
<div class="header-content">
<div class="header-content-left">
<div v-if="showMenuBtn" class="menu-btn-wrapper">
<button class="menu-btn" @click="$emit('toggle-menu')">
<button class="menu-btn" ref="menuBtn" @click="$emit('toggle-menu')">
<i class="fas fa-bars"></i>
</button>
<span v-if="isMobile && unreadCount > 0" class="menu-unread-dot"></span>
</div>
<div class="logo-container" @click="goToHome">
<NuxtLink class="logo-container" :to="`/`" @click="refrechData">
<img
alt="OpenIsle"
src="https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/image.png"
@@ -16,7 +16,7 @@
height="60"
/>
<div class="logo-text">OpenIsle</div>
</div>
</NuxtLink>
</div>
<ClientOnly>
@@ -24,6 +24,13 @@
<div v-if="isMobile" class="search-icon" @click="search">
<i class="fas fa-search"></i>
</div>
<ToolTip v-if="!isMobile" content="发帖" placement="bottom">
<div class="new-post-icon" @click="goToNewPost">
<i class="fas fa-edit"></i>
</div>
</ToolTip>
<DropdownMenu ref="userMenu" :items="headerMenuItems">
<template #trigger>
<div class="avatar-container">
@@ -48,144 +55,114 @@
</header>
</template>
<script>
import { authState, clearToken, loadCurrentUser } from '~/utils/auth'
import { watch, nextTick, ref, computed } from 'vue'
import { fetchUnreadCount, notificationState } from '~/utils/notification'
import DropdownMenu from '~/components/DropdownMenu.vue'
import SearchDropdown from '~/components/SearchDropdown.vue'
import { useIsMobile } from '~/utils/screen'
import { useRouter } from 'vue-router'
<script setup>
import { ClientOnly } from '#components'
export default {
name: 'HeaderComponent',
components: { DropdownMenu, SearchDropdown },
props: {
showMenuBtn: {
type: Boolean,
default: true,
},
import { computed, nextTick, ref, watch } from 'vue'
import DropdownMenu from '~/components/DropdownMenu.vue'
import ToolTip from '~/components/ToolTip.vue'
import SearchDropdown from '~/components/SearchDropdown.vue'
import { authState, clearToken, loadCurrentUser } from '~/utils/auth'
import { fetchUnreadCount, notificationState } from '~/utils/notification'
import { useIsMobile } from '~/utils/screen'
const props = defineProps({
showMenuBtn: {
type: Boolean,
default: true,
},
setup() {
const isLogin = computed(() => authState.loggedIn)
const isMobile = useIsMobile()
const unreadCount = computed(() => notificationState.unreadCount)
const router = useRouter()
const avatar = ref('')
const showSearch = ref(false)
const searchDropdown = ref(null)
const userMenu = ref(null)
})
const goToHome = () => {
router.push('/').then(() => {
window.location.reload()
})
}
const search = () => {
showSearch.value = true
nextTick(() => {
searchDropdown.value.toggle()
})
}
const closeSearch = () => {
nextTick(() => {
showSearch.value = false
})
}
const goToLogin = () => {
router.push('/login')
}
const goToSettings = () => {
router.push('/settings')
}
const goToProfile = async () => {
if (!authState.loggedIn) {
router.push('/login')
return
}
let id = authState.username || authState.userId
if (!id) {
const user = await loadCurrentUser()
if (user) {
id = user.username || user.id
}
}
if (id) {
router.push(`/users/${id}`)
}
}
const goToSignup = () => {
router.push('/signup')
}
const goToLogout = () => {
clearToken()
this.$router.push('/login')
}
const isLogin = computed(() => authState.loggedIn)
const isMobile = useIsMobile()
const unreadCount = computed(() => notificationState.unreadCount)
const avatar = ref('')
const showSearch = ref(false)
const searchDropdown = ref(null)
const userMenu = ref(null)
const menuBtn = ref(null)
const headerMenuItems = computed(() => [
{ text: '设置', onClick: goToSettings },
{ text: '个人主页', onClick: goToProfile },
{ text: '退出', onClick: goToLogout },
])
const search = () => {
showSearch.value = true
nextTick(() => {
searchDropdown.value.toggle()
})
}
const closeSearch = () => {
nextTick(() => {
showSearch.value = false
})
}
const goToLogin = () => {
navigateTo('/login', { replace: true })
}
const goToSettings = () => {
navigateTo('/settings', { replace: true })
}
const goToProfile = async () => {
if (!authState.loggedIn) {
navigateTo('/login', { replace: true })
return
}
let id = authState.username || authState.userId
if (!id) {
const user = await loadCurrentUser()
if (user) {
id = user.username || user.id
}
}
if (id) {
navigateTo(`/users/${id}`, { replace: true })
}
}
const goToSignup = () => {
navigateTo('/signup', { replace: true })
}
const goToLogout = () => {
clearToken()
navigateTo('/login', { replace: true })
}
onMounted(async () => {
const updateAvatar = async () => {
if (authState.loggedIn) {
const user = await loadCurrentUser()
if (user && user.avatar) {
avatar.value = user.avatar
}
}
}
const updateUnread = async () => {
if (authState.loggedIn) {
await fetchUnreadCount()
} else {
notificationState.unreadCount = 0
}
}
const goToNewPost = () => {
navigateTo('/new-post', { replace: false })
}
const refrechData = async () => {
await fetchUnreadCount()
}
const headerMenuItems = computed(() => [
{ text: '设置', onClick: goToSettings },
{ text: '个人主页', onClick: goToProfile },
{ text: '退出', onClick: goToLogout },
])
onMounted(async () => {
const updateAvatar = async () => {
if (authState.loggedIn) {
const user = await loadCurrentUser()
if (user && user.avatar) {
avatar.value = user.avatar
}
}
}
const updateUnread = async () => {
if (authState.loggedIn) {
await fetchUnreadCount()
} else {
notificationState.unreadCount = 0
}
}
await updateAvatar()
await updateUnread()
watch(
() => authState.loggedIn,
async () => {
await updateAvatar()
await updateUnread()
watch(
() => authState.loggedIn,
async () => {
await updateAvatar()
await updateUnread()
},
)
watch(
() => router.currentRoute.value.fullPath,
() => {
if (userMenu.value) userMenu.value.close()
showSearch.value = false
},
)
})
return {
isLogin,
isMobile,
headerMenuItems,
unreadCount,
goToHome,
search,
closeSearch,
goToLogin,
goToSettings,
goToProfile,
goToSignup,
goToLogout,
showSearch,
searchDropdown,
userMenu,
avatar,
}
},
}
},
)
})
</script>
<style scoped>
@@ -206,6 +183,8 @@ export default {
font-size: 20px;
font-weight: bold;
cursor: pointer;
text-decoration: none;
color: inherit;
}
.header-content {
@@ -312,6 +291,12 @@ export default {
cursor: pointer;
}
.new-post-icon {
font-size: 18px;
cursor: pointer;
margin-right: 10px;
}
@media (max-width: 1200px) {
.header-content {
padding-left: 15px;

View File

@@ -9,18 +9,9 @@
</div>
</template>
<script>
import { useRouter } from 'vue-router'
export default {
name: 'LoginOverlay',
setup() {
const router = useRouter()
const goLogin = () => {
router.push('/login')
}
return { goLogin }
},
<script setup>
const goLogin = () => {
navigateTo('/login', { replace: true })
}
</script>

View File

@@ -16,33 +16,25 @@
</BasePopup>
</template>
<script>
<script setup>
import BasePopup from '~/components/BasePopup.vue'
import { useRouter } from 'vue-router'
import { authState } from '~/utils/auth'
export default {
name: 'MedalPopup',
components: { BasePopup },
props: {
visible: { type: Boolean, default: false },
medals: { type: Array, default: () => [] },
},
emits: ['close'],
setup(props, { emit }) {
const router = useRouter()
const gotoMedals = () => {
emit('close')
if (authState.username) {
router.push(`/users/${authState.username}?tab=achievements`)
} else {
router.push('/')
}
}
const close = () => emit('close')
return { gotoMedals, close }
},
defineProps({
visible: { type: Boolean, default: false },
medals: { type: Array, default: () => [] },
})
const emit = defineEmits(['close'])
const gotoMedals = () => {
emit('close')
if (authState.username) {
navigateTo(`/users/${authState.username}?tab=achievements`, { replace: true })
} else {
navigateTo('/', { replace: true })
}
}
const close = () => emit('close')
</script>
<style scoped>

View File

@@ -1,250 +1,226 @@
<template>
<transition name="slide">
<nav v-if="visible" class="menu">
<div class="menu-item-container">
<NuxtLink class="menu-item" exact-active-class="selected" to="/" @click="handleHomeClick">
<i class="menu-item-icon fas fa-hashtag"></i>
<span class="menu-item-text">话题</span>
</NuxtLink>
<NuxtLink
class="menu-item"
exact-active-class="selected"
to="/message"
@click="handleItemClick"
>
<i class="menu-item-icon fas fa-envelope"></i>
<span class="menu-item-text">我的消息</span>
<span v-if="unreadCount > 0" class="unread-container">
<span class="unread"> {{ showUnreadCount }} </span>
</span>
</NuxtLink>
<NuxtLink
class="menu-item"
exact-active-class="selected"
to="/about"
@click="handleItemClick"
>
<i class="menu-item-icon fas fa-info-circle"></i>
<span class="menu-item-text">关于</span>
</NuxtLink>
<NuxtLink
class="menu-item"
exact-active-class="selected"
to="/activities"
@click="handleItemClick"
>
<i class="menu-item-icon fas fa-gift"></i>
<span class="menu-item-text">🔥 活动</span>
</NuxtLink>
<NuxtLink
v-if="shouldShowStats"
class="menu-item"
exact-active-class="selected"
to="/about/stats"
@click="handleItemClick"
>
<i class="menu-item-icon fas fa-chart-line"></i>
<span class="menu-item-text">站点统计</span>
</NuxtLink>
<NuxtLink
class="menu-item"
exact-active-class="selected"
to="/new-post"
@click="handleItemClick"
>
<i class="menu-item-icon fas fa-edit"></i>
<span class="menu-item-text">发帖</span>
</NuxtLink>
</div>
<div class="menu-section">
<div class="section-header" @click="categoryOpen = !categoryOpen">
<span>类别</span>
<i :class="categoryOpen ? 'fas fa-chevron-up' : 'fas fa-chevron-down'"></i>
</div>
<div v-if="categoryOpen" class="section-items">
<div v-if="isLoadingCategory" class="menu-loading-container">
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
</div>
<div
v-else
v-for="c in categoryData"
:key="c.id"
class="section-item"
@click="gotoCategory(c)"
<div class="menu-content">
<div class="menu-item-container">
<NuxtLink class="menu-item" exact-active-class="selected" to="/" @click="handleItemClick">
<i class="menu-item-icon fas fa-hashtag"></i>
<span class="menu-item-text">话题</span>
</NuxtLink>
<NuxtLink
class="menu-item"
exact-active-class="selected"
to="/new-post"
@click="handleItemClick"
>
<template v-if="c.smallIcon || c.icon">
<img
v-if="isImageIcon(c.smallIcon || c.icon)"
:src="c.smallIcon || c.icon"
class="section-item-icon"
:alt="c.name"
/>
<i v-else :class="['section-item-icon', c.smallIcon || c.icon]"></i>
</template>
<span class="section-item-text">
{{ c.name }}
<span class="section-item-text-count" v-if="c.count >= 0">x {{ c.count }}</span>
<i class="menu-item-icon fas fa-edit"></i>
<span class="menu-item-text">发帖</span>
</NuxtLink>
<NuxtLink
class="menu-item"
exact-active-class="selected"
to="/message"
@click="handleItemClick"
>
<i class="menu-item-icon fas fa-envelope"></i>
<span class="menu-item-text">我的消息</span>
<span v-if="unreadCount > 0" class="unread-container">
<span class="unread"> {{ showUnreadCount }} </span>
</span>
</div>
</NuxtLink>
<NuxtLink
class="menu-item"
exact-active-class="selected"
to="/about"
@click="handleItemClick"
>
<i class="menu-item-icon fas fa-info-circle"></i>
<span class="menu-item-text">关于</span>
</NuxtLink>
<NuxtLink
class="menu-item"
exact-active-class="selected"
to="/activities"
@click="handleItemClick"
>
<i class="menu-item-icon fas fa-gift"></i>
<span class="menu-item-text">🔥 活动</span>
</NuxtLink>
<NuxtLink
v-if="shouldShowStats"
class="menu-item"
exact-active-class="selected"
to="/about/stats"
@click="handleItemClick"
>
<i class="menu-item-icon fas fa-chart-line"></i>
<span class="menu-item-text">站点统计</span>
</NuxtLink>
</div>
</div>
<div class="menu-section">
<div class="section-header" @click="tagOpen = !tagOpen">
<span>tag</span>
<i :class="tagOpen ? 'fas fa-chevron-up' : 'fas fa-chevron-down'"></i>
</div>
<div v-if="tagOpen" class="section-items">
<div v-if="isLoadingTag" class="menu-loading-container">
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
<div class="menu-section">
<div class="section-header" @click="categoryOpen = !categoryOpen">
<span>类别</span>
<i :class="categoryOpen ? 'fas fa-chevron-up' : 'fas fa-chevron-down'"></i>
</div>
<div v-else v-for="t in tagData" :key="t.id" class="section-item" @click="gotoTag(t)">
<img
v-if="isImageIcon(t.smallIcon || t.icon)"
:src="t.smallIcon || t.icon"
class="section-item-icon"
:alt="t.name"
/>
<i v-else class="section-item-icon fas fa-hashtag"></i>
<span class="section-item-text"
>{{ t.name }} <span class="section-item-text-count">x {{ t.count }}</span></span
<div v-if="categoryOpen" class="section-items">
<div v-if="isLoadingCategory" class="menu-loading-container">
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
</div>
<div
v-else
v-for="c in categoryData"
:key="c.id"
class="section-item"
@click="gotoCategory(c)"
>
<template v-if="c.smallIcon || c.icon">
<img
v-if="isImageIcon(c.smallIcon || c.icon)"
:src="c.smallIcon || c.icon"
class="section-item-icon"
:alt="c.name"
/>
<i v-else :class="['section-item-icon', c.smallIcon || c.icon]"></i>
</template>
<span class="section-item-text">
{{ c.name }}
<span class="section-item-text-count" v-if="c.count >= 0">x {{ c.count }}</span>
</span>
</div>
</div>
</div>
<div class="menu-section">
<div class="section-header" @click="tagOpen = !tagOpen">
<span>tag</span>
<i :class="tagOpen ? 'fas fa-chevron-up' : 'fas fa-chevron-down'"></i>
</div>
<div v-if="tagOpen" class="section-items">
<div v-if="isLoadingTag" class="menu-loading-container">
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
</div>
<div v-else v-for="t in tagData" :key="t.id" class="section-item" @click="gotoTag(t)">
<img
v-if="isImageIcon(t.smallIcon || t.icon)"
:src="t.smallIcon || t.icon"
class="section-item-icon"
:alt="t.name"
/>
<i v-else class="section-item-icon fas fa-hashtag"></i>
<span class="section-item-text"
>{{ t.name }} <span class="section-item-text-count">x {{ t.count }}</span></span
>
</div>
</div>
</div>
</div>
<div class="menu-footer">
<div class="menu-footer-btn" @click="cycleTheme">
<i :class="iconClass"></i>
<!-- 解决动态样式的水合错误 -->
<ClientOnly>
<div class="menu-footer">
<div class="menu-footer-btn" @click="cycleTheme">
<i :class="iconClass"></i>
</div>
</div>
</div>
</ClientOnly>
</nav>
</transition>
</template>
<script>
<script setup>
import { ref, computed, watch, onMounted } from 'vue'
import { themeState, cycleTheme, ThemeMode } from '~/utils/theme'
import { authState } from '~/utils/auth'
import { fetchUnreadCount, notificationState } from '~/utils/notification'
import { ref, computed, watch, onMounted } from 'vue'
import { API_BASE_URL } from '~/main'
export default {
name: 'MenuComponent',
props: {
visible: {
type: Boolean,
default: true,
},
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
const props = defineProps({
visible: { type: Boolean, default: true },
})
const emit = defineEmits(['item-click'])
const categoryOpen = ref(true)
const tagOpen = ref(true)
/** ✅ 用 useAsyncData 替换原生 fetch避免 SSR+CSR 二次请求 */
const {
data: categoryData,
pending: isLoadingCategory,
error: categoryError,
} = await useAsyncData(
// 稳定 key避免 hydration 期误判
'menu:categories',
() => $fetch(`${API_BASE_URL}/api/categories`),
{
server: true, // SSR 预取
default: () => [], // 初始默认值,减少空判断
// 5 分钟内复用缓存,避免路由往返重复请求
staleTime: 5 * 60 * 1000,
},
async setup(props, { emit }) {
const router = useRouter()
const categories = ref([])
const tags = ref([])
const categoryOpen = ref(true)
const tagOpen = ref(true)
const isLoadingCategory = ref(false)
const isLoadingTag = ref(false)
const categoryData = ref([])
const tagData = ref([])
)
const fetchCategoryData = async () => {
isLoadingCategory.value = true
const res = await fetch(`${API_BASE_URL}/api/categories`)
const data = await res.json()
categoryData.value = data
isLoadingCategory.value = false
}
const {
data: tagData,
pending: isLoadingTag,
error: tagError,
} = await useAsyncData('menu:tags', () => $fetch(`${API_BASE_URL}/api/tags?limit=10`), {
server: true,
default: () => [],
staleTime: 5 * 60 * 1000,
})
const fetchTagData = async () => {
isLoadingTag.value = true
const res = await fetch(`${API_BASE_URL}/api/tags?limit=10`)
const data = await res.json()
tagData.value = data
isLoadingTag.value = false
}
/** 其余逻辑保持不变 */
const iconClass = computed(() => {
switch (themeState.mode) {
case ThemeMode.DARK:
return 'fas fa-moon'
case ThemeMode.LIGHT:
return 'fas fa-sun'
default:
return 'fas fa-desktop'
}
})
const iconClass = computed(() => {
switch (themeState.mode) {
case ThemeMode.DARK:
return 'fas fa-moon'
case ThemeMode.LIGHT:
return 'fas fa-sun'
default:
return 'fas fa-desktop'
}
})
const unreadCount = computed(() => notificationState.unreadCount)
const showUnreadCount = computed(() => (unreadCount.value > 99 ? '99+' : unreadCount.value))
const shouldShowStats = computed(() => authState.role === 'ADMIN')
const unreadCount = computed(() => notificationState.unreadCount)
const showUnreadCount = computed(() => (unreadCount.value > 99 ? '99+' : unreadCount.value))
const shouldShowStats = computed(() => authState.role === 'ADMIN')
const updateCount = async () => {
if (authState.loggedIn) {
await fetchUnreadCount()
} else {
notificationState.unreadCount = 0
}
}
const updateCount = async () => {
if (authState.loggedIn) {
await fetchUnreadCount()
} else {
notificationState.unreadCount = 0
}
}
onMounted(async () => {
await updateCount()
// 登录态变化时再拉一次未读数;与 useAsyncData 无关
watch(() => authState.loggedIn, updateCount)
})
onMounted(async () => {
await updateCount()
watch(() => authState.loggedIn, updateCount)
})
const handleItemClick = () => {
if (window.innerWidth <= 768) emit('item-click')
}
const handleHomeClick = () => {
router.push('/').then(() => {
window.location.reload()
})
}
const isImageIcon = (icon) => {
if (!icon) return false
return /^https?:\/\//.test(icon) || icon.startsWith('/')
}
const handleItemClick = () => {
if (window.innerWidth <= 768) emit('item-click')
}
const gotoCategory = (c) => {
const value = encodeURIComponent(c.id ?? c.name)
navigateTo({ path: '/', query: { category: value } }, { replace: true })
handleItemClick()
}
const isImageIcon = (icon) => {
if (!icon) return false
return /^https?:\/\//.test(icon) || icon.startsWith('/')
}
const gotoCategory = (c) => {
const value = encodeURIComponent(c.id ?? c.name)
router.push({ path: '/', query: { category: value } }).then(() => {
window.location.reload()
})
handleItemClick()
}
const gotoTag = (t) => {
const value = encodeURIComponent(t.id ?? t.name)
router.push({ path: '/', query: { tags: value } }).then(() => {
window.location.reload()
})
handleItemClick()
}
await Promise.all([fetchCategoryData(), fetchTagData()])
return {
categoryData,
tagData,
categoryOpen,
tagOpen,
isLoadingCategory,
isLoadingTag,
iconClass,
unreadCount,
showUnreadCount,
shouldShowStats,
cycleTheme,
handleHomeClick,
handleItemClick,
isImageIcon,
gotoCategory,
gotoTag,
}
},
const gotoTag = (t) => {
const value = encodeURIComponent(t.id ?? t.name)
navigateTo({ path: '/', query: { tags: value } }, { replace: true })
handleItemClick()
}
</script>
@@ -252,20 +228,28 @@ export default {
.menu {
position: sticky;
top: var(--header-height);
width: 200px;
width: 220px;
background-color: var(--menu-background-color);
height: calc(100vh - 20px - var(--header-height));
border-right: 1px solid var(--menu-border-color);
display: flex;
flex-direction: column;
padding: 10px;
overflow-y: auto;
scrollbar-width: none;
}
.menu-item-container {
.menu-content {
width: 100%;
height: 100%;
overflow-y: auto;
box-sizing: border-box;
padding: 10px 10px 0 10px;
}
/* .menu-item-container { */
/**/
/* } */
.menu-item {
padding: 4px 10px;
text-decoration: none;
@@ -311,10 +295,8 @@ export default {
}
.menu-footer {
position: fixed;
position: relation;
height: 30px;
bottom: 10px;
right: 10px;
display: flex;
align-items: center;
justify-content: flex-end;
@@ -402,6 +384,10 @@ export default {
background-color: var(--background-color-blur);
}
.menu-content {
border-radius: 20px;
}
.slide-enter-active,
.slide-leave-active {
transition:

View File

@@ -57,73 +57,66 @@
</div>
</template>
<script>
import { API_BASE_URL, toast } from '~/main'
<script setup>
import { toast } from '~/main'
import { fetchCurrentUser, getToken } from '~/utils/auth'
import BaseInput from '~/components/BaseInput.vue'
import BasePopup from '~/components/BasePopup.vue'
import LevelProgress from '~/components/LevelProgress.vue'
import ProgressBar from '~/components/ProgressBar.vue'
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
export default {
name: 'MilkTeaActivityComponent',
components: { ProgressBar, LevelProgress, BaseInput, BasePopup },
data() {
return {
info: { redeemCount: 0, ended: false },
user: null,
dialogVisible: false,
contact: '',
loading: false,
isLoadingUser: true,
const info = ref({ redeemCount: 0, ended: false })
const user = ref(null)
const dialogVisible = ref(false)
const contact = ref('')
const loading = ref(false)
const isLoadingUser = ref(true)
onMounted(async () => {
await loadInfo()
isLoadingUser.value = true
user.value = await fetchCurrentUser()
isLoadingUser.value = false
})
const loadInfo = async () => {
const res = await fetch(`${API_BASE_URL}/api/activities/milk-tea`)
if (res.ok) {
info.value = await res.json()
}
}
const openDialog = () => {
dialogVisible.value = true
}
const closeDialog = () => {
dialogVisible.value = false
}
const submitRedeem = async () => {
if (!contact.value) return
loading.value = true
const token = getToken()
const res = await fetch(`${API_BASE_URL}/api/activities/milk-tea/redeem`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ contact: contact.value }),
})
if (res.ok) {
const data = await res.json()
if (data.message === 'updated') {
toast.success('您已提交过兑换,本次更新兑换信息')
} else {
toast.success('兑换成功!')
}
},
async mounted() {
await this.loadInfo()
this.isLoadingUser = true
this.user = await fetchCurrentUser()
this.isLoadingUser = false
},
methods: {
async loadInfo() {
const res = await fetch(`${API_BASE_URL}/api/activities/milk-tea`)
if (res.ok) {
this.info = await res.json()
}
},
openDialog() {
this.dialogVisible = true
},
closeDialog() {
this.dialogVisible = false
},
async submitRedeem() {
if (!this.contact) return
this.loading = true
const token = getToken()
const res = await fetch(`${API_BASE_URL}/api/activities/milk-tea/redeem`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ contact: this.contact }),
})
if (res.ok) {
const data = await res.json()
if (data.message === 'updated') {
toast.success('您已提交过兑换,本次更新兑换信息')
} else {
toast.success('兑换成功!')
}
this.dialogVisible = false
await this.loadInfo()
} else {
toast.error('兑换失败')
}
this.loading = false
},
},
dialogVisible.value = false
await loadInfo()
} else {
toast.error('兑换失败')
}
loading.value = false
}
</script>

View File

@@ -11,27 +11,19 @@
</BasePopup>
</template>
<script>
<script setup>
import BasePopup from '~/components/BasePopup.vue'
import { useRouter } from 'vue-router'
export default {
name: 'NotificationSettingPopup',
components: { BasePopup },
props: {
visible: { type: Boolean, default: false },
},
emits: ['close'],
setup(props, { emit }) {
const router = useRouter()
const gotoSetting = () => {
emit('close')
router.push('/message?tab=control')
}
const close = () => emit('close')
return { gotoSetting, close }
},
defineProps({
visible: { type: Boolean, default: false },
})
const emit = defineEmits(['close'])
const gotoSetting = () => {
emit('close')
navigateTo('/message?tab=control', { replace: true })
}
const close = () => emit('close')
</script>
<style scoped>

View File

@@ -46,11 +46,27 @@
</div>
</template>
<script>
<script setup>
import { computed, onMounted, ref, watch } from 'vue'
import { API_BASE_URL, toast } from '~/main'
import { toast } from '~/main'
import { authState, getToken } from '~/utils/auth'
import { reactionEmojiMap } from '~/utils/reactions'
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
const emit = defineEmits(['update:modelValue'])
const props = defineProps({
modelValue: { type: Array, default: () => [] },
contentType: { type: String, required: true },
contentId: { type: [Number, String], required: true },
})
watch(
() => props.modelValue,
(v) => (reactions.value = v),
)
const reactions = ref(props.modelValue)
const reactionTypes = ref([])
let cachedTypes = null
const fetchTypes = async () => {
@@ -71,151 +87,118 @@ const fetchTypes = async () => {
return cachedTypes
}
export default {
name: 'ReactionsGroup',
props: {
modelValue: { type: Array, default: () => [] },
contentType: { type: String, required: true },
contentId: { type: [Number, String], required: true },
},
emits: ['update:modelValue'],
setup(props, { emit }) {
const reactions = ref(props.modelValue)
watch(
() => props.modelValue,
(v) => (reactions.value = v),
)
onMounted(async () => {
reactionTypes.value = await fetchTypes()
})
const reactionTypes = ref([])
onMounted(async () => {
reactionTypes.value = await fetchTypes()
const counts = computed(() => {
const c = {}
for (const r of reactions.value) {
c[r.type] = (c[r.type] || 0) + 1
}
return c
})
const totalCount = computed(() => Object.values(counts.value).reduce((a, b) => a + b, 0))
const likeCount = computed(() => counts.value['LIKE'] || 0)
const userReacted = (type) =>
reactions.value.some((r) => r.type === type && r.user === authState.username)
const displayedReactions = computed(() => {
return Object.entries(counts.value)
.sort((a, b) => b[1] - a[1])
.slice(0, 3)
.map(([type]) => ({ type }))
})
const panelTypes = computed(() => reactionTypes.value.filter((t) => t !== 'LIKE'))
const panelVisible = ref(false)
let hideTimer = null
const openPanel = () => {
clearTimeout(hideTimer)
panelVisible.value = true
}
const scheduleHide = () => {
clearTimeout(hideTimer)
hideTimer = setTimeout(() => {
panelVisible.value = false
}, 500)
}
const cancelHide = () => {
clearTimeout(hideTimer)
}
const toggleReaction = async (type) => {
const token = getToken()
if (!token) {
toast.error('请先登录')
return
}
const url =
props.contentType === 'post'
? `${API_BASE_URL}/api/posts/${props.contentId}/reactions`
: `${API_BASE_URL}/api/comments/${props.contentId}/reactions`
// optimistic update
const existingIdx = reactions.value.findIndex(
(r) => r.type === type && r.user === authState.username,
)
let tempReaction = null
let removedReaction = null
if (existingIdx > -1) {
removedReaction = reactions.value.splice(existingIdx, 1)[0]
} else {
tempReaction = { type, user: authState.username }
reactions.value.push(tempReaction)
}
emit('update:modelValue', reactions.value)
try {
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({ type }),
})
const counts = computed(() => {
const c = {}
for (const r of reactions.value) {
c[r.type] = (c[r.type] || 0) + 1
}
return c
})
const totalCount = computed(() => Object.values(counts.value).reduce((a, b) => a + b, 0))
const likeCount = computed(() => counts.value['LIKE'] || 0)
const userReacted = (type) =>
reactions.value.some((r) => r.type === type && r.user === authState.username)
const displayedReactions = computed(() => {
return Object.entries(counts.value)
.sort((a, b) => b[1] - a[1])
.slice(0, 3)
.map(([type]) => ({ type }))
})
const panelTypes = computed(() => reactionTypes.value.filter((t) => t !== 'LIKE'))
const panelVisible = ref(false)
let hideTimer = null
const openPanel = () => {
clearTimeout(hideTimer)
panelVisible.value = true
}
const scheduleHide = () => {
clearTimeout(hideTimer)
hideTimer = setTimeout(() => {
panelVisible.value = false
}, 500)
}
const cancelHide = () => {
clearTimeout(hideTimer)
}
const toggleReaction = async (type) => {
const token = getToken()
if (!token) {
toast.error('请先登录')
return
}
const url =
props.contentType === 'post'
? `${API_BASE_URL}/api/posts/${props.contentId}/reactions`
: `${API_BASE_URL}/api/comments/${props.contentId}/reactions`
// optimistic update
const existingIdx = reactions.value.findIndex(
(r) => r.type === type && r.user === authState.username,
)
let tempReaction = null
let removedReaction = null
if (existingIdx > -1) {
removedReaction = reactions.value.splice(existingIdx, 1)[0]
if (res.ok) {
if (res.status === 204) {
// removal already reflected
} else {
tempReaction = { type, user: authState.username }
reactions.value.push(tempReaction)
const data = await res.json()
const idx = tempReaction ? reactions.value.indexOf(tempReaction) : -1
if (idx > -1) {
reactions.value.splice(idx, 1, data)
} else if (removedReaction) {
// server added back reaction even though we removed? restore data
reactions.value.push(data)
}
if (data.reward && data.reward > 0) {
toast.success(`获得 ${data.reward} 经验值`)
}
}
emit('update:modelValue', reactions.value)
try {
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({ type }),
})
if (res.ok) {
if (res.status === 204) {
// removal already reflected
} else {
const data = await res.json()
const idx = tempReaction ? reactions.value.indexOf(tempReaction) : -1
if (idx > -1) {
reactions.value.splice(idx, 1, data)
} else if (removedReaction) {
// server added back reaction even though we removed? restore data
reactions.value.push(data)
}
if (data.reward && data.reward > 0) {
toast.success(`获得 ${data.reward} 经验值`)
}
}
emit('update:modelValue', reactions.value)
} else {
// revert optimistic update on failure
if (tempReaction) {
const idx = reactions.value.indexOf(tempReaction)
if (idx > -1) reactions.value.splice(idx, 1)
} else if (removedReaction) {
reactions.value.push(removedReaction)
}
emit('update:modelValue', reactions.value)
toast.error('操作失败')
}
} catch (e) {
if (tempReaction) {
const idx = reactions.value.indexOf(tempReaction)
if (idx > -1) reactions.value.splice(idx, 1)
} else if (removedReaction) {
reactions.value.push(removedReaction)
}
emit('update:modelValue', reactions.value)
toast.error('操作失败')
} else {
// revert optimistic update on failure
if (tempReaction) {
const idx = reactions.value.indexOf(tempReaction)
if (idx > -1) reactions.value.splice(idx, 1)
} else if (removedReaction) {
reactions.value.push(removedReaction)
}
emit('update:modelValue', reactions.value)
toast.error('操作失败')
}
return {
reactionEmojiMap,
counts,
totalCount,
likeCount,
displayedReactions,
panelTypes,
panelVisible,
openPanel,
scheduleHide,
cancelHide,
toggleReaction,
userReacted,
} catch (e) {
if (tempReaction) {
const idx = reactions.value.indexOf(tempReaction)
if (idx > -1) reactions.value.splice(idx, 1)
} else if (removedReaction) {
reactions.value.push(removedReaction)
}
},
emit('update:modelValue', reactions.value)
toast.error('操作失败')
}
}
</script>

View File

@@ -36,98 +36,84 @@
</div>
</template>
<script>
import { ref, watch } from 'vue'
<script setup>
import { useIsMobile } from '~/utils/screen'
import { useRouter } from 'vue-router'
import Dropdown from '~/components/Dropdown.vue'
import { API_BASE_URL } from '~/main'
import { stripMarkdown } from '~/utils/markdown'
import { ref, watch } from 'vue'
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
export default {
name: 'SearchDropdown',
components: { Dropdown },
emits: ['close'],
setup(props, { emit }) {
const router = useRouter()
const keyword = ref('')
const selected = ref(null)
const results = ref([])
const dropdown = ref(null)
const isMobile = useIsMobile()
const emit = defineEmits(['close'])
const toggle = () => {
dropdown.value.toggle()
}
const keyword = ref('')
const selected = ref(null)
const results = ref([])
const dropdown = ref(null)
const isMobile = useIsMobile()
const onClose = () => emit('close')
const fetchResults = async (kw) => {
if (!kw) return []
const res = await fetch(`${API_BASE_URL}/api/search/global?keyword=${encodeURIComponent(kw)}`)
if (!res.ok) return []
const data = await res.json()
results.value = data.map((r) => ({
id: r.id,
text: r.text,
type: r.type,
subText: r.subText,
extra: r.extra,
postId: r.postId,
}))
return results.value
}
const highlight = (text) => {
text = stripMarkdown(text)
if (!keyword.value) return text
const reg = new RegExp(keyword.value, 'gi')
const res = text.replace(reg, (m) => `<span class="highlight">${m}</span>`)
return res
}
const iconMap = {
user: 'fas fa-user',
post: 'fas fa-file-alt',
comment: 'fas fa-comment',
category: 'fas fa-folder',
tag: 'fas fa-hashtag',
}
watch(selected, (val) => {
if (!val) return
const opt = results.value.find((r) => r.id === val)
if (!opt) return
if (opt.type === 'post' || opt.type === 'post_title') {
router.push(`/posts/${opt.id}`)
} else if (opt.type === 'user') {
router.push(`/users/${opt.id}`)
} else if (opt.type === 'comment') {
if (opt.postId) {
router.push(`/posts/${opt.postId}#comment-${opt.id}`)
}
} else if (opt.type === 'category') {
router.push({ path: '/', query: { category: opt.id } })
} else if (opt.type === 'tag') {
router.push({ path: '/', query: { tags: opt.id } })
}
selected.value = null
keyword.value = ''
})
return {
keyword,
selected,
fetchResults,
highlight,
iconMap,
isMobile,
dropdown,
onClose,
toggle,
}
},
const toggle = () => {
dropdown.value.toggle()
}
const onClose = () => emit('close')
const fetchResults = async (kw) => {
if (!kw) return []
const res = await fetch(`${API_BASE_URL}/api/search/global?keyword=${encodeURIComponent(kw)}`)
if (!res.ok) return []
const data = await res.json()
results.value = data.map((r) => ({
id: r.id,
text: r.text,
type: r.type,
subText: r.subText,
extra: r.extra,
postId: r.postId,
}))
return results.value
}
const highlight = (text) => {
text = stripMarkdown(text)
if (!keyword.value) return text
const reg = new RegExp(keyword.value, 'gi')
const res = text.replace(reg, (m) => `<span class="highlight">${m}</span>`)
return res
}
const iconMap = {
user: 'fas fa-user',
post: 'fas fa-file-alt',
comment: 'fas fa-comment',
category: 'fas fa-folder',
tag: 'fas fa-hashtag',
}
watch(selected, (val) => {
if (!val) return
const opt = results.value.find((r) => r.id === val)
if (!opt) return
if (opt.type === 'post' || opt.type === 'post_title') {
navigateTo(`/posts/${opt.id}`, { replace: true })
} else if (opt.type === 'user') {
navigateTo(`/users/${opt.id}`, { replace: true })
} else if (opt.type === 'comment') {
if (opt.postId) {
navigateTo(`/posts/${opt.postId}#comment-${opt.id}`, { replace: true })
}
} else if (opt.type === 'category') {
navigateTo({ path: '/', query: { category: opt.id } }, { replace: true })
} else if (opt.type === 'tag') {
navigateTo({ path: '/', query: { tags: opt.id } }, { replace: true })
}
selected.value = null
keyword.value = ''
})
defineExpose({
toggle,
})
</script>
<style scoped>

View File

@@ -28,114 +28,105 @@
</Dropdown>
</template>
<script>
<script setup>
import { computed, ref, watch } from 'vue'
import { API_BASE_URL, toast } from '~/main'
import { toast } from '~/main'
import Dropdown from '~/components/Dropdown.vue'
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
export default {
name: 'TagSelect',
components: { Dropdown },
props: {
modelValue: { type: Array, default: () => [] },
creatable: { type: Boolean, default: false },
options: { type: Array, default: () => [] },
const emit = defineEmits(['update:modelValue'])
const props = defineProps({
modelValue: { type: Array, default: () => [] },
creatable: { type: Boolean, default: false },
options: { type: Array, default: () => [] },
})
const localTags = ref([])
const providedTags = ref(Array.isArray(props.options) ? [...props.options] : [])
watch(
() => props.options,
(val) => {
providedTags.value = Array.isArray(val) ? [...val] : []
},
emits: ['update:modelValue'],
setup(props, { emit }) {
const localTags = ref([])
const providedTags = ref(Array.isArray(props.options) ? [...props.options] : [])
)
watch(
() => props.options,
(val) => {
providedTags.value = Array.isArray(val) ? [...val] : []
},
)
const mergedOptions = computed(() => {
const arr = [...providedTags.value, ...localTags.value]
return arr.filter((v, i, a) => a.findIndex((t) => t.id === v.id) === i)
})
const mergedOptions = computed(() => {
const arr = [...providedTags.value, ...localTags.value]
return arr.filter((v, i, a) => a.findIndex((t) => t.id === v.id) === i)
})
const isImageIcon = (icon) => {
if (!icon) return false
return /^https?:\/\//.test(icon) || icon.startsWith('/')
}
const buildTagsUrl = (kw = '') => {
const base = API_BASE_URL || (process.client ? window.location.origin : '')
const url = new URL('/api/tags', base)
if (kw) url.searchParams.set('keyword', kw)
url.searchParams.set('limit', '10')
return url.toString()
}
const fetchTags = async (kw = '') => {
const defaultOption = { id: 0, name: '无标签' }
// 1) 先拼 URL自动兜底到 window.location.origin
const url = buildTagsUrl(kw)
// 2) 拉数据
let data = []
try {
const res = await fetch(url)
if (res.ok) data = await res.json()
} catch {
toast.error('获取标签失败')
}
// 3) 合并、去重、可创建
let options = [...data, ...localTags.value]
if (
props.creatable &&
kw &&
!options.some((t) => t.name.toLowerCase() === kw.toLowerCase())
) {
options.push({ id: `__create__:${kw}`, name: `创建"${kw}"` })
}
options = Array.from(new Map(options.map((t) => [t.id, t])).values())
// 4) 最终结果
return [defaultOption, ...options]
}
const selected = computed({
get: () => props.modelValue,
set: (v) => {
if (Array.isArray(v)) {
if (v.includes(0)) {
emit('update:modelValue', [])
return
}
if (v.length > 2) {
toast.error('最多选择两个标签')
return
}
v = v.map((id) => {
if (typeof id === 'string' && id.startsWith('__create__:')) {
const name = id.slice(11)
const newId = `__new__:${name}`
if (!localTags.value.find((t) => t.id === newId)) {
localTags.value.push({ id: newId, name })
}
return newId
}
return id
})
}
emit('update:modelValue', v)
},
})
return { fetchTags, selected, isImageIcon, mergedOptions }
},
const isImageIcon = (icon) => {
if (!icon) return false
return /^https?:\/\//.test(icon) || icon.startsWith('/')
}
const buildTagsUrl = (kw = '') => {
const base = API_BASE_URL || (process.client ? window.location.origin : '')
const url = new URL('/api/tags', base)
if (kw) url.searchParams.set('keyword', kw)
url.searchParams.set('limit', '10')
return url.toString()
}
const fetchTags = async (kw = '') => {
const defaultOption = { id: 0, name: '无标签' }
// 1) 先拼 URL自动兜底到 window.location.origin
const url = buildTagsUrl(kw)
// 2) 拉数据
let data = []
try {
const res = await fetch(url)
if (res.ok) data = await res.json()
} catch {
toast.error('获取标签失败')
}
// 3) 合并、去重、可创建
let options = [...data, ...localTags.value]
if (props.creatable && kw && !options.some((t) => t.name.toLowerCase() === kw.toLowerCase())) {
options.push({ id: `__create__:${kw}`, name: `创建"${kw}"` })
}
options = Array.from(new Map(options.map((t) => [t.id, t])).values())
// 4) 最终结果
return [defaultOption, ...options]
}
const selected = computed({
get: () => props.modelValue,
set: (v) => {
if (Array.isArray(v)) {
if (v.includes(0)) {
emit('update:modelValue', [])
return
}
if (v.length > 2) {
toast.error('最多选择两个标签')
return
}
v = v.map((id) => {
if (typeof id === 'string' && id.startsWith('__create__:')) {
const name = id.slice(11)
const newId = `__new__:${name}`
if (!localTags.value.find((t) => t.id === newId)) {
localTags.value.push({ id: newId, name })
}
return newId
}
return id
})
}
emit('update:modelValue', v)
},
})
</script>
<style scoped>

View File

@@ -0,0 +1,488 @@
<template>
<div class="tooltip-wrapper" ref="wrapperRef">
<!-- 触发器 -->
<div
class="tooltip-trigger"
@mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave"
@click="handleClick"
@focus="handleFocus"
@blur="handleBlur"
:tabindex="focusable ? 0 : -1"
>
<slot />
</div>
<!-- 提示内容 -->
<Transition name="tooltip-fade">
<div
v-if="visible"
ref="tooltipRef"
class="tooltip-content"
:class="[
`tooltip-${placement}`,
{ 'tooltip-dark': dark },
{ 'tooltip-light': !dark }
]"
:style="tooltipStyle"
role="tooltip"
:aria-describedby="ariaId"
>
<div class="tooltip-inner">
<slot name="content">
{{ content }}
</slot>
</div>
<div class="tooltip-arrow" :class="`tooltip-arrow-${placement}`"></div>
</div>
</Transition>
</div>
</template>
<script>
import { ref, computed, onMounted, onBeforeUnmount, nextTick, useId, watch } from 'vue'
export default {
name: 'ToolTip',
props: {
// 提示内容
content: {
type: String,
default: ''
},
// 触发方式hover、click、focus
trigger: {
type: String,
default: 'hover',
validator: (value) => ['hover', 'click', 'focus', 'manual'].includes(value)
},
// 位置top、bottom、left、right
placement: {
type: String,
default: 'top',
validator: (value) => ['top', 'bottom', 'left', 'right'].includes(value)
},
// 是否启用暗色主题
dark: {
type: Boolean,
default: false
},
// 延迟显示时间(毫秒)
delay: {
type: Number,
default: 100
},
// 是否禁用
disabled: {
type: Boolean,
default: false
},
// 是否可通过Tab键聚焦
focusable: {
type: Boolean,
default: true
},
// 偏移距离
offset: {
type: Number,
default: 8
},
// 最大宽度
maxWidth: {
type: [String, Number],
default: '200px'
}
},
emits: ['show', 'hide'],
setup(props, { emit }) {
const wrapperRef = ref(null)
const tooltipRef = ref(null)
const visible = ref(false)
const ariaId = ref(`tooltip-${useId()}`)
let showTimer = null
let hideTimer = null
// 计算tooltip样式
const tooltipStyle = computed(() => {
const maxWidth = typeof props.maxWidth === 'number'
? `${props.maxWidth}px`
: props.maxWidth
return {
maxWidth,
zIndex: 2000
}
})
// 显示tooltip
const show = () => {
if (props.disabled) return
clearTimeout(hideTimer)
showTimer = setTimeout(() => {
visible.value = true
emit('show')
nextTick(() => {
updatePosition()
})
}, props.delay)
}
// 隐藏tooltip
const hide = () => {
clearTimeout(showTimer)
hideTimer = setTimeout(() => {
visible.value = false
emit('hide')
}, 100)
}
// 立即显示用于manual模式
const showImmediately = () => {
if (props.disabled) return
clearTimeout(hideTimer)
clearTimeout(showTimer)
visible.value = true
emit('show')
nextTick(() => {
updatePosition()
})
}
// 立即隐藏用于manual模式
const hideImmediately = () => {
clearTimeout(showTimer)
clearTimeout(hideTimer)
visible.value = false
emit('hide')
}
// 更新位置
const updatePosition = () => {
if (!wrapperRef.value || !tooltipRef.value) return
const trigger = wrapperRef.value.querySelector('.tooltip-trigger')
const tooltip = tooltipRef.value
if (!trigger) return
const triggerRect = trigger.getBoundingClientRect()
const tooltipRect = tooltip.getBoundingClientRect()
let top = 0
let left = 0
switch (props.placement) {
case 'top':
top = triggerRect.top - tooltipRect.height - props.offset
left = triggerRect.left + (triggerRect.width - tooltipRect.width) / 2
break
case 'bottom':
top = triggerRect.bottom + props.offset
left = triggerRect.left + (triggerRect.width - tooltipRect.width) / 2
break
case 'left':
top = triggerRect.top + (triggerRect.height - tooltipRect.height) / 2
left = triggerRect.left - tooltipRect.width - props.offset
break
case 'right':
top = triggerRect.top + (triggerRect.height - tooltipRect.height) / 2
left = triggerRect.right + props.offset
break
}
// 边界检测
const padding = 8
const viewportWidth = window.innerWidth
const viewportHeight = window.innerHeight
if (left < padding) {
left = padding
} else if (left + tooltipRect.width > viewportWidth - padding) {
left = viewportWidth - tooltipRect.width - padding
}
if (top < padding) {
top = padding
} else if (top + tooltipRect.height > viewportHeight - padding) {
top = viewportHeight - tooltipRect.height - padding
}
tooltip.style.position = 'fixed'
tooltip.style.top = `${top}px`
tooltip.style.left = `${left}px`
}
// 事件处理
const handleMouseEnter = () => {
if (props.trigger === 'hover') {
show()
}
}
const handleMouseLeave = () => {
if (props.trigger === 'hover') {
hide()
}
}
const handleClick = () => {
if (props.trigger === 'click') {
if (visible.value) {
hide()
} else {
show()
}
}
}
const handleFocus = () => {
if (props.trigger === 'focus') {
show()
}
}
const handleBlur = () => {
if (props.trigger === 'focus') {
hide()
}
}
// 点击外部隐藏
const handleClickOutside = (event) => {
if (props.trigger === 'click' && wrapperRef.value && !wrapperRef.value.contains(event.target)) {
hide()
}
}
// 窗口大小改变时重新计算位置
const handleResize = () => {
if (visible.value) {
updatePosition()
}
}
// 监听禁用状态变化
watch(() => props.disabled, (newVal) => {
if (newVal && visible.value) {
hideImmediately()
}
})
onMounted(() => {
document.addEventListener('click', handleClickOutside)
window.addEventListener('resize', handleResize)
window.addEventListener('scroll', handleResize)
})
onBeforeUnmount(() => {
clearTimeout(showTimer)
clearTimeout(hideTimer)
document.removeEventListener('click', handleClickOutside)
window.removeEventListener('resize', handleResize)
window.removeEventListener('scroll', handleResize)
})
return {
wrapperRef,
tooltipRef,
visible,
ariaId,
tooltipStyle,
handleMouseEnter,
handleMouseLeave,
handleClick,
handleFocus,
handleBlur,
// 暴露给父组件的方法
show: showImmediately,
hide: hideImmediately
}
}
}
</script>
<style scoped>
.tooltip-wrapper {
position: relative;
display: inline-block;
}
.tooltip-trigger {
display: inline-block;
outline: none;
}
.tooltip-content {
position: fixed;
pointer-events: none;
z-index: 2000;
}
.tooltip-inner {
padding: 8px 12px;
border-radius: 6px;
font-size: 14px;
line-height: 1.4;
word-wrap: break-word;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
/* 亮色主题 */
.tooltip-light .tooltip-inner {
background-color: var(--background-color);
color: var(--text-color);
border: 1px solid var(--normal-border-color);
}
/* 暗色主题 */
.tooltip-dark .tooltip-inner {
background-color: rgba(0, 0, 0, 0.9);
color: white;
}
/* 箭头基础样式 */
.tooltip-arrow {
position: absolute;
width: 0;
height: 0;
border-style: solid;
}
/* 顶部箭头 */
.tooltip-top .tooltip-arrow-top {
bottom: -6px;
left: 50%;
transform: translateX(-50%);
border-width: 6px 6px 0 6px;
}
.tooltip-light.tooltip-top .tooltip-arrow-top {
border-color: var(--normal-border-color) transparent transparent transparent;
}
.tooltip-light.tooltip-top .tooltip-arrow-top::after {
content: '';
position: absolute;
top: -7px;
left: -6px;
border-width: 6px 6px 0 6px;
border-style: solid;
border-color: var(--background-color) transparent transparent transparent;
}
.tooltip-dark.tooltip-top .tooltip-arrow-top {
border-color: rgba(0, 0, 0, 0.9) transparent transparent transparent;
}
/* 底部箭头 */
.tooltip-bottom .tooltip-arrow-bottom {
top: -6px;
left: 50%;
transform: translateX(-50%);
border-width: 0 6px 6px 6px;
}
.tooltip-light.tooltip-bottom .tooltip-arrow-bottom {
border-color: transparent transparent var(--normal-border-color) transparent;
}
.tooltip-light.tooltip-bottom .tooltip-arrow-bottom::after {
content: '';
position: absolute;
top: 1px;
left: -6px;
border-width: 0 6px 6px 6px;
border-style: solid;
border-color: transparent transparent var(--background-color) transparent;
}
.tooltip-dark.tooltip-bottom .tooltip-arrow-bottom {
border-color: transparent transparent rgba(0, 0, 0, 0.9) transparent;
}
/* 左侧箭头 */
.tooltip-left .tooltip-arrow-left {
right: -6px;
top: 50%;
transform: translateY(-50%);
border-width: 6px 0 6px 6px;
}
.tooltip-light.tooltip-left .tooltip-arrow-left {
border-color: transparent transparent transparent var(--normal-border-color);
}
.tooltip-light.tooltip-left .tooltip-arrow-left::after {
content: '';
position: absolute;
top: -6px;
left: -7px;
border-width: 6px 0 6px 6px;
border-style: solid;
border-color: transparent transparent transparent var(--background-color);
}
.tooltip-dark.tooltip-left .tooltip-arrow-left {
border-color: transparent transparent transparent rgba(0, 0, 0, 0.9);
}
/* 右侧箭头 */
.tooltip-right .tooltip-arrow-right {
left: -6px;
top: 50%;
transform: translateY(-50%);
border-width: 6px 6px 6px 0;
}
.tooltip-light.tooltip-right .tooltip-arrow-right {
border-color: transparent var(--normal-border-color) transparent transparent;
}
.tooltip-light.tooltip-right .tooltip-arrow-right::after {
content: '';
position: absolute;
top: -6px;
left: 1px;
border-width: 6px 6px 6px 0;
border-style: solid;
border-color: transparent var(--background-color) transparent transparent;
}
.tooltip-dark.tooltip-right .tooltip-arrow-right {
border-color: transparent rgba(0, 0, 0, 0.9) transparent transparent;
}
/* 过渡动画 */
.tooltip-fade-enter-active,
.tooltip-fade-leave-active {
transition: all 0.2s ease;
}
.tooltip-fade-enter-from {
opacity: 0;
transform: scale(0.8);
}
.tooltip-fade-leave-to {
opacity: 0;
transform: scale(0.8);
}
/* 响应式调整 */
@media (max-width: 768px) {
.tooltip-inner {
padding: 6px 10px;
font-size: 13px;
max-width: 250px;
}
}
/* 键盘导航样式 */
.tooltip-trigger:focus-visible {
outline: 2px solid var(--primary-color);
outline-offset: 2px;
border-radius: 4px;
}
</style>

View File

@@ -11,20 +11,15 @@
</div>
</template>
<script>
<script setup>
import BasePlaceholder from '~/components/BasePlaceholder.vue'
export default {
name: 'UserList',
components: { BasePlaceholder },
props: {
users: { type: Array, default: () => [] },
},
methods: {
handleUserClick(user) {
this.$router.push(`/users/${user.id}`)
},
},
defineProps({
users: { type: Array, default: () => [] },
})
const handleUserClick = (user) => {
navigateTo(`/users/${user.id}`, { replace: true })
}
</script>

View File

@@ -1 +0,0 @@
export const WEBSITE_BASE_URL = 'https://www.open-isle.com'

View File

@@ -1,11 +1 @@
export const API_BASE_URL = 'https://www.open-isle.com'
// export const API_BASE_URL = 'http://127.0.0.1:8081'
// export const API_BASE_URL = 'http://30.211.97.238:8081'
export const GOOGLE_CLIENT_ID =
'777830451304-nt8afkkap18gui4f9entcha99unal744.apps.googleusercontent.com'
export const GITHUB_CLIENT_ID = 'Ov23liVkO1NPAX5JyWxJ'
export const DISCORD_CLIENT_ID = '1394985417044000779'
export const TWITTER_CLIENT_ID = 'ZTRTU05KSk9KTTJrTTdrVC1tc1E6MTpjaQ'
// 重新导出 toast 功能,使用 composable 方式
export { toast } from './composables/useToast'

View File

@@ -2,8 +2,18 @@ import { defineNuxtConfig } from 'nuxt/config'
export default defineNuxtConfig({
ssr: true,
// Ensure Vditor styles load before our overrides in global.css
css: ['vditor/dist/index.css', '~/assets/global.css'],
runtimeConfig: {
public: {
apiBaseUrl: process.env.NUXT_PUBLIC_API_BASE_URL || '',
websiteBaseUrl: process.env.NUXT_PUBLIC_WEBSITE_BASE_URL || '',
googleClientId: process.env.NUXT_PUBLIC_GOOGLE_CLIENT_ID || '',
githubClientId: process.env.NUXT_PUBLIC_GITHUB_CLIENT_ID || '',
discordClientId: process.env.NUXT_PUBLIC_DISCORD_CLIENT_ID || '',
twitterClientId: process.env.NUXT_PUBLIC_TWITTER_CLIENT_ID || '',
},
},
// 确保 Vditor 样式在 global.css 覆盖前加载
css: ['vditor/dist/index.css', '~/assets/fonts.css', '~/assets/global.css'],
app: {
head: {
script: [
@@ -42,5 +52,35 @@ export default defineNuxtConfig({
},
],
},
baseURL: '/',
buildAssetsDir: '/_nuxt/',
},
vue: {
compilerOptions: {
isCustomElement: (tag) => ['l-hatch', 'l-hatch-spinner'].includes(tag),
},
},
vite: {
build: {
// increase warning limit and split large libraries into separate chunks
chunkSizeWarningLimit: 1024,
rollupOptions: {
output: {
manualChunks(id) {
if (id.includes('node_modules')) {
if (id.includes('vditor')) {
return 'vditor'
}
if (id.includes('echarts')) {
return 'echarts'
}
if (id.includes('highlight.js')) {
return 'highlight'
}
}
},
},
},
},
},
})

View File

File diff suppressed because it is too large Load Diff

View File

@@ -41,8 +41,9 @@ import { use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
import { onMounted, ref } from 'vue'
import VChart from 'vue-echarts'
import { API_BASE_URL } from '~/main'
import { getToken } from '~/utils/auth'
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
use([LineChart, TitleComponent, TooltipComponent, GridComponent, DataZoomComponent, CanvasRenderer])

View File

@@ -29,35 +29,28 @@
</div>
</template>
<script>
import { API_BASE_URL } from '~/main'
<script setup>
import TimeManager from '~/utils/time'
import MilkTeaActivityComponent from '~/components/MilkTeaActivityComponent.vue'
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
export default {
name: 'ActivityListPageView',
components: { MilkTeaActivityComponent },
data() {
return {
activities: [],
TimeManager,
isLoadingActivities: false,
const activities = ref([])
const isLoadingActivities = ref(false)
onMounted(async () => {
isLoadingActivities.value = true
try {
const res = await fetch(`${API_BASE_URL}/api/activities`)
if (res.ok) {
activities.value = await res.json()
}
},
async mounted() {
this.isLoadingActivities = true
try {
const res = await fetch(`${API_BASE_URL}/api/activities`)
if (res.ok) {
this.activities = await res.json()
}
} catch (e) {
console.error(e)
} finally {
this.isLoadingActivities = false
}
},
}
} catch (e) {
console.error(e)
} finally {
isLoadingActivities.value = false
}
})
</script>
<style scoped>

View File

@@ -2,24 +2,20 @@
<CallbackPage />
</template>
<script>
<script setup>
import CallbackPage from '~/components/CallbackPage.vue'
import { discordExchange } from '~/utils/discord'
export default {
name: 'DiscordCallbackPageView',
components: { CallbackPage },
async mounted() {
const url = new URL(window.location.href)
const code = url.searchParams.get('code')
const state = url.searchParams.get('state')
const result = await discordExchange(code, state, '')
onMounted(async () => {
const url = new URL(window.location.href)
const code = url.searchParams.get('code')
const state = url.searchParams.get('state')
const result = await discordExchange(code, state, '')
if (result.needReason) {
this.$router.push('/signup-reason?token=' + result.token)
} else {
this.$router.push('/')
}
},
}
if (result.needReason) {
navigateTo(`/signup-reason?token=${result.token}`, { replace: true })
} else {
navigateTo('/', { replace: true })
}
})
</script>

View File

@@ -2,6 +2,7 @@
<div class="forgot-page">
<div class="forgot-content">
<div class="forgot-title">找回密码</div>
<div v-if="step === 0" class="step-content">
<BaseInput icon="fas fa-envelope" v-model="email" placeholder="邮箱" />
<div v-if="emailError" class="error-message">{{ emailError }}</div>
@@ -19,109 +20,110 @@
<div class="primary-button" @click="resetPassword" v-if="!isResetting">重置密码</div>
<div class="primary-button disabled" v-else>提交中...</div>
</div>
<div class="hint-message">
<i class="fas fa-info-circle"></i>
使用 Google 注册的用户可使用对应的邮箱进行找回密码
</div>
</div>
</div>
</template>
<script>
import { API_BASE_URL, toast } from '~/main'
<script setup>
import { toast } from '~/main'
import BaseInput from '~/components/BaseInput.vue'
export default {
name: 'ForgotPasswordPageView',
components: { BaseInput },
data() {
return {
step: 0,
email: '',
code: '',
password: '',
token: '',
emailError: '',
passwordError: '',
isSending: false,
isVerifying: false,
isResetting: false,
import { useRoute } from 'vue-router'
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
const step = ref(0)
const email = ref('')
const code = ref('')
const password = ref('')
const token = ref('')
const emailError = ref('')
const passwordError = ref('')
const isSending = ref(false)
const isVerifying = ref(false)
const isResetting = ref(false)
const route = useRoute()
onMounted(() => {
if (route.query.email) {
email.value = decodeURIComponent(route.query.email)
}
})
const sendCode = async () => {
if (!email.value) {
emailError.value = '邮箱不能为空'
return
}
try {
isSending.value = true
const res = await fetch(`${API_BASE_URL}/api/auth/forgot/send`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: email.value }),
})
isSending.value = false
if (res.ok) {
toast.success('验证码已发送')
step.value = 1
} else {
toast.error('请填写已注册邮箱')
}
},
mounted() {
if (this.$route.query.email) {
this.email = decodeURIComponent(this.$route.query.email)
} catch (e) {
isSending.value = false
toast.error('发送失败')
}
}
const verifyCode = async () => {
try {
isVerifying.value = true
const res = await fetch(`${API_BASE_URL}/api/auth/forgot/verify`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: email.value, code: code.value }),
})
isVerifying.value = false
const data = await res.json()
if (res.ok) {
token.value = data.token
step.value = 2
} else {
toast.error(data.error || '验证失败')
}
},
methods: {
async sendCode() {
if (!this.email) {
this.emailError = '邮箱不能为空'
return
}
try {
this.isSending = true
const res = await fetch(`${API_BASE_URL}/api/auth/forgot/send`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: this.email }),
})
this.isSending = false
if (res.ok) {
toast.success('验证码已发送')
this.step = 1
} else {
toast.error('请填写已注册邮箱')
}
} catch (e) {
this.isSending = false
toast.error('发送失败')
}
},
async verifyCode() {
try {
this.isVerifying = true
const res = await fetch(`${API_BASE_URL}/api/auth/forgot/verify`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: this.email, code: this.code }),
})
this.isVerifying = false
const data = await res.json()
if (res.ok) {
this.token = data.token
this.step = 2
} else {
toast.error(data.error || '验证失败')
}
} catch (e) {
this.isVerifying = false
toast.error('验证失败')
}
},
async resetPassword() {
if (!this.password) {
this.passwordError = '密码不能为空'
return
}
try {
this.isResetting = true
const res = await fetch(`${API_BASE_URL}/api/auth/forgot/reset`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: this.token, password: this.password }),
})
this.isResetting = false
const data = await res.json()
if (res.ok) {
toast.success('密码已重置')
this.$router.push('/login')
} else if (data.field === 'password') {
this.passwordError = data.error
} else {
toast.error(data.error || '重置失败')
}
} catch (e) {
this.isResetting = false
toast.error('重置失败')
}
},
},
} catch (e) {
isVerifying.value = false
toast.error('验证失败')
}
}
const resetPassword = async () => {
if (!password.value) {
passwordError.value = '密码不能为空'
return
}
try {
isResetting.value = true
const res = await fetch(`${API_BASE_URL}/api/auth/forgot/reset`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: token.value, password: password.value }),
})
isResetting.value = false
const data = await res.json()
if (res.ok) {
toast.success('密码已重置')
navigateTo('/login', { replace: true })
} else if (data.field === 'password') {
passwordError.value = data.error
} else {
toast.error(data.error || '重置失败')
}
} catch (e) {
isResetting.value = false
toast.error('重置失败')
}
}
</script>
@@ -143,6 +145,21 @@ export default {
font-size: 24px;
font-weight: bold;
}
.forgot-content .hint-message {
display: flex;
justify-content: flex-start;
align-items: center;
gap: 8px;
padding: 8px 0;
font-size: 13px;
color: var(--blockquote-text-color);
}
.hint-message i {
color: var(--primary-color);
font-size: 14px;
}
.step-content {
display: flex;
flex-direction: column;

View File

@@ -2,24 +2,20 @@
<CallbackPage />
</template>
<script>
<script setup>
import CallbackPage from '~/components/CallbackPage.vue'
import { githubExchange } from '~/utils/github'
export default {
name: 'GithubCallbackPageView',
components: { CallbackPage },
async mounted() {
const url = new URL(window.location.href)
const code = url.searchParams.get('code')
const state = url.searchParams.get('state')
const result = await githubExchange(code, state, '')
onMounted(async () => {
const url = new URL(window.location.href)
const code = url.searchParams.get('code')
const state = url.searchParams.get('state')
const result = await githubExchange(code, state, '')
if (result.needReason) {
this.$router.push('/signup-reason?token=' + result.token)
} else {
this.$router.push('/')
}
},
}
if (result.needReason) {
navigateTo(`/signup-reason?token=${result.token}`, { replace: true })
} else {
navigateTo('/', { replace: true })
}
})
</script>

View File

@@ -2,29 +2,25 @@
<CallbackPage />
</template>
<script>
<script setup>
import CallbackPage from '~/components/CallbackPage.vue'
import { googleAuthWithToken } from '~/utils/google'
export default {
name: 'GoogleCallbackPageView',
components: { CallbackPage },
async mounted() {
const hash = new URLSearchParams(window.location.hash.substring(1))
const idToken = hash.get('id_token')
if (idToken) {
await googleAuthWithToken(
idToken,
() => {
this.$router.push('/')
},
(token) => {
this.$router.push('/signup-reason?token=' + token)
},
)
} else {
this.$router.push('/login')
}
},
}
onMounted(async () => {
const hash = new URLSearchParams(window.location.hash.substring(1))
const idToken = hash.get('id_token')
if (idToken) {
await googleAuthWithToken(
idToken,
() => {
navigateTo('/', { replace: true })
},
(token) => {
navigateTo(`/signup-reason?token=${token}`, { replace: true })
},
)
} else {
navigateTo('/login', { replace: true })
}
})
</script>

View File

@@ -1,10 +1,7 @@
<template>
<div class="home-page">
<div v-if="!isMobile" class="search-container">
<div class="search-title">一切可能从此刻启航</div>
<div class="search-subtitle">
愿你在此遇见灵感与共鸣若有疑惑欢迎发问亦可在知识的海洋中搜寻答案
</div>
<div class="search-title">一切可能从此刻启航在此遇见灵感与共鸣</div>
<SearchDropdown />
</div>
@@ -50,7 +47,7 @@
</div>
</div>
<div v-if="isLoadingPosts && articles.length === 0" class="loading-container">
<div v-if="pendingFirst" class="loading-container">
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
</div>
@@ -60,7 +57,12 @@
</div>
</div>
<div class="article-item" v-for="article in articles" :key="article.id">
<div
v-if="!pendingFirst"
class="article-item"
v-for="article in articles"
:key="article.id"
>
<div class="article-main-container">
<NuxtLink class="article-item-title main-item" :to="`/posts/${article.id}`">
<i v-if="article.pinned" class="fas fa-thumbtack pinned-icon"></i>
@@ -104,320 +106,255 @@
热门帖子功能开发中,敬请期待。
</div>
<div v-else class="placeholder-container">分类浏览功能开发中,敬请期待。</div>
<div v-if="isLoadingPosts && articles.length > 0" class="loading-container bottom-loading">
<div v-if="isLoadingMore && articles.length > 0" class="loading-container bottom-loading">
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
</div>
</div>
</div>
</template>
<script>
import { ref, watch } from 'vue'
import { useRoute } from 'vue-router'
<script setup>
import { computed, onMounted, ref, watch } from 'vue'
import ArticleCategory from '~/components/ArticleCategory.vue'
import ArticleTags from '~/components/ArticleTags.vue'
import CategorySelect from '~/components/CategorySelect.vue'
import SearchDropdown from '~/components/SearchDropdown.vue'
import TagSelect from '~/components/TagSelect.vue'
import { getToken } from '~/utils/auth'
import { useScrollLoadMore } from '~/utils/loadMore'
import { stripMarkdown } from '~/utils/markdown'
import { API_BASE_URL } from '~/main'
import { getToken } from '~/utils/auth'
import TimeManager from '~/utils/time'
import CategorySelect from '~/components/CategorySelect.vue'
import TagSelect from '~/components/TagSelect.vue'
import ArticleTags from '~/components/ArticleTags.vue'
import ArticleCategory from '~/components/ArticleCategory.vue'
import SearchDropdown from '~/components/SearchDropdown.vue'
import { useIsMobile } from '~/utils/screen'
import TimeManager from '~/utils/time'
export default {
name: 'HomePageView',
components: {
CategorySelect,
TagSelect,
ArticleTags,
ArticleCategory,
SearchDropdown,
ClientOnly: () =>
import('vue').then((m) =>
m.defineAsyncComponent(() => import('vue').then(() => ({ template: '<slot />' }))),
),
},
async setup() {
useHead({
title: 'OpenIsle - 全面开源的自由社区',
meta: [
{
name: 'description',
content:
'OpenIsle 是一个开放的技术与交流社区,致力于为开发者、技术爱好者和创作者们提供一个自由、友好、包容的讨论与协作环境。我们鼓励用户在这里分享知识、交流经验、提出问题、展示作品,并共同推动技术进步与社区成长。',
},
],
})
const route = useRoute()
const selectedCategory = ref('')
if (route.query.category) {
const c = decodeURIComponent(route.query.category)
selectedCategory.value = isNaN(c) ? c : Number(c)
}
const selectedTags = ref([])
if (route.query.tags) {
const t = Array.isArray(route.query.tags) ? route.query.tags.join(',') : route.query.tags
selectedTags.value = t
.split(',')
.filter((v) => v)
.map((v) => decodeURIComponent(v))
.map((v) => (isNaN(v) ? v : Number(v)))
}
useHead({
title: 'OpenIsle - 全面开源的自由社区',
meta: [
{
name: 'description',
content:
'OpenIsle 是一个开放的技术与交流社区,致力于为开发者、技术爱好者和创作者们提供一个自由、友好、包容的讨论与协作环境。我们鼓励用户在这里分享知识、交流经验、提出问题、展示作品,并共同推动技术进步与社区成长。',
},
],
})
const tagOptions = ref([])
const categoryOptions = ref([])
const isLoadingPosts = ref(false)
const topics = ref(['最新回复', '最新', '排行榜' /*, '热门', '类别'*/])
const selectedTopic = ref(
route.query.view === 'ranking'
? '排行榜'
: route.query.view === 'latest'
? '最新'
: '最新回复',
)
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
const selectedCategory = ref('')
const selectedTags = ref([])
const route = useRoute()
const tagOptions = ref([])
const categoryOptions = ref([])
const articles = ref([])
const page = ref(0)
const pageSize = 10
const isMobile = useIsMobile()
const allLoaded = ref(false)
const isLoadingMore = ref(false)
const loadOptions = async () => {
if (selectedCategory.value && !isNaN(selectedCategory.value)) {
try {
const res = await fetch(`${API_BASE_URL}/api/categories/${selectedCategory.value}`)
if (res.ok) {
categoryOptions.value = [await res.json()]
}
} catch (e) {
/* ignore */
}
}
const topics = ref(['最新回复', '最新', '排行榜' /*, '热门', '类别'*/])
const selectedTopic = ref(
route.query.view === 'ranking' ? '排行榜' : route.query.view === 'latest' ? '最新' : '最新回复',
)
const articles = ref([])
const page = ref(0)
const pageSize = 10
const isMobile = useIsMobile()
const allLoaded = ref(false)
if (selectedTags.value.length) {
const arr = []
for (const t of selectedTags.value) {
if (!isNaN(t)) {
try {
const r = await fetch(`${API_BASE_URL}/api/tags/${t}`)
if (r.ok) arr.push(await r.json())
} catch (e) {
/* ignore */
}
}
}
tagOptions.value = arr
}
}
const buildUrl = () => {
let url = `${API_BASE_URL}/api/posts?page=${page.value}&pageSize=${pageSize}`
if (selectedCategory.value) {
url += `&categoryId=${selectedCategory.value}`
}
if (selectedTags.value.length) {
selectedTags.value.forEach((t) => {
url += `&tagIds=${t}`
})
}
return url
}
const buildRankUrl = () => {
let url = `${API_BASE_URL}/api/posts/ranking?page=${page.value}&pageSize=${pageSize}`
if (selectedCategory.value) {
url += `&categoryId=${selectedCategory.value}`
}
if (selectedTags.value.length) {
selectedTags.value.forEach((t) => {
url += `&tagIds=${t}`
})
}
return url
}
const buildReplyUrl = () => {
let url = `${API_BASE_URL}/api/posts/latest-reply?page=${page.value}&pageSize=${pageSize}`
if (selectedCategory.value) {
url += `&categoryId=${selectedCategory.value}`
}
if (selectedTags.value.length) {
selectedTags.value.forEach((t) => {
url += `&tagIds=${t}`
})
}
return url
}
const fetchPosts = async (reset = false) => {
if (reset) {
page.value = 0
allLoaded.value = false
articles.value = []
}
if (isLoadingPosts.value || allLoaded.value) return
try {
isLoadingPosts.value = true
const token = getToken()
const res = await fetch(buildUrl(), {
headers: {
Authorization: token ? `Bearer ${token}` : '',
},
})
isLoadingPosts.value = false
if (!res.ok) return
const data = await res.json()
articles.value.push(
...data.map((p) => ({
id: p.id,
title: p.title,
description: p.content,
category: p.category,
tags: p.tags || [],
members: (p.participants || []).map((m) => ({ id: m.id, avatar: m.avatar })),
comments: p.commentCount,
views: p.views,
time: TimeManager.format(p.createdAt),
pinned: !!p.pinnedAt,
type: p.type,
})),
)
if (data.length < pageSize) {
allLoaded.value = true
} else {
page.value += 1
}
} catch (e) {
console.error(e)
}
}
const fetchRanking = async (reset = false) => {
if (reset) {
page.value = 0
allLoaded.value = false
articles.value = []
}
if (isLoadingPosts.value || allLoaded.value) return
try {
isLoadingPosts.value = true
const token = getToken()
const res = await fetch(buildRankUrl(), {
headers: {
Authorization: token ? `Bearer ${token}` : '',
},
})
isLoadingPosts.value = false
if (!res.ok) return
const data = await res.json()
articles.value.push(
...data.map((p) => ({
id: p.id,
title: p.title,
description: p.content,
category: p.category,
tags: p.tags || [],
members: (p.participants || []).map((m) => ({ id: m.id, avatar: m.avatar })),
comments: p.commentCount,
views: p.views,
time: TimeManager.format(p.createdAt),
pinned: !!p.pinnedAt,
type: p.type,
})),
)
if (data.length < pageSize) {
allLoaded.value = true
} else {
page.value += 1
}
} catch (e) {
console.error(e)
}
}
const fetchLatestReply = async (reset = false) => {
if (reset) {
page.value = 0
allLoaded.value = false
articles.value = []
}
if (isLoadingPosts.value || allLoaded.value) return
try {
isLoadingPosts.value = true
const token = getToken()
const res = await fetch(buildReplyUrl(), {
headers: {
Authorization: token ? `Bearer ${token}` : '',
},
})
isLoadingPosts.value = false
if (!res.ok) return
const data = await res.json()
articles.value.push(
...data.map((p) => ({
id: p.id,
title: p.title,
description: p.content,
category: p.category,
tags: p.tags || [],
members: (p.participants || []).map((m) => ({ id: m.id, avatar: m.avatar })),
comments: p.commentCount,
views: p.views,
time: TimeManager.format(p.lastReplyAt || p.createdAt),
pinned: !!p.pinnedAt,
type: p.type,
})),
)
if (data.length < pageSize) {
allLoaded.value = true
} else {
page.value += 1
}
} catch (e) {
console.error(e)
}
}
const fetchContent = async (reset = false) => {
if (selectedTopic.value === '排行榜') {
await fetchRanking(reset)
} else if (selectedTopic.value === '最新回复') {
await fetchLatestReply(reset)
} else {
await fetchPosts(reset)
}
}
useScrollLoadMore(fetchContent)
watch([selectedCategory, selectedTags], () => {
fetchContent(true)
})
watch(selectedTopic, () => {
fetchContent(true)
})
const sanitizeDescription = (text) => stripMarkdown(text)
await Promise.all([loadOptions(), fetchContent()])
return {
topics,
selectedTopic,
articles,
sanitizeDescription,
isLoadingPosts,
selectedCategory,
selectedTags,
tagOptions,
categoryOptions,
isMobile,
}
},
/** URL 参数 -> 本地筛选值 **/
const selectedCategorySet = (category) => {
const c = decodeURIComponent(category)
selectedCategory.value = isNaN(c) ? c : Number(c)
}
const selectedTagsSet = (tags) => {
const t = Array.isArray(tags) ? tags.join(',') : tags
selectedTags.value = t
.split(',')
.filter((v) => v)
.map((v) => decodeURIComponent(v))
.map((v) => (isNaN(v) ? v : Number(v)))
}
/** 初始化:仅在客户端首渲染时根据路由同步一次 **/
onMounted(() => {
const { category, tags } = route.query
if (category) selectedCategorySet(category)
if (tags) selectedTagsSet(tags)
})
/** 路由变更时同步筛选 **/
watch(
() => route.query,
(query) => {
const category = query.category
const tags = query.tags
category && selectedCategorySet(category)
tags && selectedTagsSet(tags)
},
)
/** 选项加载(分类/标签名称回填) **/
const loadOptions = async () => {
if (selectedCategory.value && !isNaN(selectedCategory.value)) {
try {
const res = await fetch(`${API_BASE_URL}/api/categories/`)
if (res.ok) categoryOptions.value = [await res.json()]
} catch {}
}
if (selectedTags.value.length) {
const arr = []
for (const t of selectedTags.value) {
if (!isNaN(t)) {
try {
const r = await fetch(`${API_BASE_URL}/api/tags/${t}`)
if (r.ok) arr.push(await r.json())
} catch {}
}
}
tagOptions.value = arr
}
}
/** 列表 API 路径与查询参数 **/
const baseQuery = computed(() => ({
categoryId: selectedCategory.value || undefined,
tagIds: selectedTags.value.length ? selectedTags.value : undefined,
}))
const listApiPath = computed(() => {
if (selectedTopic.value === '排行榜') return '/api/posts/ranking'
if (selectedTopic.value === '最新回复') return '/api/posts/latest-reply'
return '/api/posts'
})
const buildUrl = ({ pageNo }) => {
const url = new URL(`${API_BASE_URL}${listApiPath.value}`)
url.searchParams.set('page', pageNo)
url.searchParams.set('pageSize', pageSize)
if (baseQuery.value.categoryId) url.searchParams.set('categoryId', baseQuery.value.categoryId)
if (baseQuery.value.tagIds)
for (const t of baseQuery.value.tagIds) url.searchParams.append('tagIds', t)
return url.toString()
}
const tokenHeader = computed(() => {
const token = getToken()
return token ? { Authorization: `Bearer ${token}` } : {}
})
/** —— 首屏数据托管SSR —— **/
const asyncKey = computed(() => [
'home:firstpage',
selectedTopic.value,
String(baseQuery.value.categoryId ?? ''),
JSON.stringify(baseQuery.value.tagIds ?? []),
])
const {
data: firstPage,
pending: pendingFirst,
refresh: refreshFirst,
} = await useAsyncData(
() => asyncKey.value.join('::'),
async () => {
const res = await $fetch(buildUrl({ pageNo: 0 }), { headers: tokenHeader.value })
const data = Array.isArray(res) ? res : []
return data.map((p) => ({
id: p.id,
title: p.title,
description: p.content,
category: p.category,
tags: p.tags || [],
members: (p.participants || []).map((m) => ({ id: m.id, avatar: m.avatar })),
comments: p.commentCount,
views: p.views,
time: TimeManager.format(
selectedTopic.value === '最新回复' ? p.lastReplyAt || p.createdAt : p.createdAt,
),
pinned: Boolean(p.pinned ?? p.pinnedAt ?? p.pinned_at),
type: p.type,
}))
},
{
server: true,
default: () => [],
watch: [selectedTopic, baseQuery],
},
)
/** 首屏/筛选变更:重置分页并灌入 firstPage **/
watch(
firstPage,
(data) => {
page.value = 0
articles.value = [...(data || [])]
allLoaded.value = (data?.length || 0) < pageSize
},
{ immediate: true },
)
/** —— 滚动加载更多 —— **/
let inflight = null
const fetchNextPage = async () => {
if (allLoaded.value || pendingFirst.value || inflight) return
const nextPage = page.value + 1
isLoadingMore.value = true
inflight = $fetch(buildUrl({ pageNo: nextPage }), { headers: tokenHeader.value })
.then((res) => {
const data = Array.isArray(res) ? res : []
const mapped = data.map((p) => ({
id: p.id,
title: p.title,
description: p.content,
category: p.category,
tags: p.tags || [],
members: (p.participants || []).map((m) => ({ id: m.id, avatar: m.avatar })),
comments: p.commentCount,
views: p.views,
time: TimeManager.format(
selectedTopic.value === '最新回复' ? p.lastReplyAt || p.createdAt : p.createdAt,
),
pinned: Boolean(p.pinned ?? p.pinnedAt ?? p.pinned_at),
type: p.type,
}))
articles.value.push(...mapped)
if (data.length < pageSize) {
allLoaded.value = true
} else {
page.value = nextPage
}
})
.finally(() => {
inflight = null
isLoadingMore.value = false
})
}
/** 绑定滚动加载(避免挂载瞬间触发) **/
let initialReady = false
const loadMoreGuarded = async () => {
if (!initialReady) return
await fetchNextPage()
}
useScrollLoadMore(loadMoreGuarded)
watch(
articles,
() => {
if (!initialReady && articles.value.length) initialReady = true
},
{ immediate: true },
)
/** 切换分类/标签/TabuseAsyncData 已 watch这里只需确保 options 加载 **/
watch([selectedCategory, selectedTags], () => {
loadOptions()
})
watch(selectedTopic, () => {
// 仅当需要额外选项时加载
loadOptions()
})
/** 选项首屏加载:服务端执行一次;客户端兜底 **/
if (import.meta.server) {
await loadOptions()
}
onMounted(() => {
if (categoryOptions.value.length === 0 && tagOptions.value.length === 0) loadOptions()
})
/** 其他工具函数 **/
const sanitizeDescription = (text) => stripMarkdown(text)
</script>
<style scoped>
@@ -431,8 +368,8 @@ export default {
}
.search-container {
margin-top: 100px;
padding: 20px;
margin-top: 32px;
padding: 20px 20px 32px;
display: flex;
flex-direction: column;
align-items: center;
@@ -444,9 +381,6 @@ export default {
font-weight: bold;
}
.search-subtitle {
font-size: 16px;
}
.loading-container {
display: flex;
@@ -696,6 +630,7 @@ export default {
.header-item.activity {
width: 10%;
}
.article-member-avatar-item:nth-child(n + 4) {
display: none;
}

View File

@@ -51,8 +51,8 @@
</div>
</template>
<script>
import { API_BASE_URL, toast } from '~/main'
<script setup>
import { toast } from '~/main'
import { setToken, loadCurrentUser } from '~/utils/auth'
import { googleAuthorize } from '~/utils/google'
import { githubAuthorize } from '~/utils/github'
@@ -60,63 +60,56 @@ import { discordAuthorize } from '~/utils/discord'
import { twitterAuthorize } from '~/utils/twitter'
import BaseInput from '~/components/BaseInput.vue'
import { registerPush } from '~/utils/push'
export default {
name: 'LoginPageView',
components: { BaseInput },
setup() {
return { googleAuthorize }
},
data() {
return {
username: '',
password: '',
isWaitingForLogin: false,
}
},
methods: {
async submitLogin() {
try {
this.isWaitingForLogin = true
const res = await fetch(`${API_BASE_URL}/api/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: this.username, password: this.password }),
})
const data = await res.json()
if (res.ok && data.token) {
setToken(data.token)
await loadCurrentUser()
toast.success('登录成功')
registerPush()
this.$router.push('/')
} else if (data.reason_code === 'NOT_VERIFIED') {
toast.info('当前邮箱未验证,已经为您重新发送验证码')
this.$router.push({ path: '/signup', query: { verify: 1, u: this.username } })
} else if (data.reason_code === 'IS_APPROVING') {
toast.info('您的注册正在审批中, 请留意邮件')
this.$router.push('/')
} else if (data.reason_code === 'NOT_APPROVED') {
this.$router.push('/signup-reason?token=' + data.token)
} else {
toast.error(data.error || '登录失败')
}
} catch (e) {
toast.error('登录失败')
} finally {
this.isWaitingForLogin = false
}
},
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
const username = ref('')
const password = ref('')
const isWaitingForLogin = ref(false)
loginWithGithub() {
githubAuthorize()
},
loginWithDiscord() {
discordAuthorize()
},
loginWithTwitter() {
twitterAuthorize()
},
},
const submitLogin = async () => {
try {
isWaitingForLogin.value = true
const res = await fetch(`${API_BASE_URL}/api/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: username.value, password: password.value }),
})
const data = await res.json()
if (res.ok && data.token) {
setToken(data.token)
await loadCurrentUser()
toast.success('登录成功')
registerPush()
await navigateTo('/', { replace: true })
} else if (data.reason_code === 'NOT_VERIFIED') {
toast.info('当前邮箱未验证,已经为您重新发送验证码')
await navigateTo(
{ path: '/signup', query: { verify: '1', u: username.value } },
{ replace: true },
)
} else if (data.reason_code === 'IS_APPROVING') {
toast.info('您的注册正在审批中, 请留意邮件')
await navigateTo('/', { replace: true })
} else if (data.reason_code === 'NOT_APPROVED') {
await navigateTo({ path: '/signup-reason', query: { token: data.token } }, { replace: true })
} else {
toast.error(data.error || '登录失败')
}
} catch (e) {
toast.error('登录失败')
} finally {
isWaitingForLogin.value = false
}
}
const loginWithGithub = () => {
githubAuthorize()
}
const loginWithDiscord = () => {
discordAuthorize()
}
const loginWithTwitter = () => {
twitterAuthorize()
}
</script>

View File

@@ -185,6 +185,32 @@
</router-link>
</NotificationContainer>
</template>
<template v-else-if="item.type === 'LOTTERY_WIN'">
<NotificationContainer :item="item" :markRead="markRead">
恭喜你在抽奖贴
<router-link
class="notif-content-text"
@click="markRead(item.id)"
:to="`/posts/${item.post.id}`"
>
{{ stripMarkdownLength(item.post.title, 100) }}
</router-link>
中获奖
</NotificationContainer>
</template>
<template v-else-if="item.type === 'LOTTERY_DRAW'">
<NotificationContainer :item="item" :markRead="markRead">
您的抽奖贴
<router-link
class="notif-content-text"
@click="markRead(item.id)"
:to="`/posts/${item.post.id}`"
>
{{ stripMarkdownLength(item.post.title, 100) }}
</router-link>
已开奖
</NotificationContainer>
</template>
<template v-else-if="item.type === 'POST_UPDATED'">
<NotificationContainer :item="item" :markRead="markRead">
您关注的帖子
@@ -478,347 +504,125 @@
</div>
</template>
<script>
import { ref, onMounted, computed } from 'vue'
import { useRouter } from 'vue-router'
import { API_BASE_URL } from '~/main'
import BaseTimeline from '~/components/BaseTimeline.vue'
<script setup>
import { computed, onMounted, ref } from 'vue'
import BasePlaceholder from '~/components/BasePlaceholder.vue'
import BaseTimeline from '~/components/BaseTimeline.vue'
import NotificationContainer from '~/components/NotificationContainer.vue'
import { getToken, authState } from '~/utils/auth'
import { markNotificationsRead, fetchUnreadCount, notificationState } from '~/utils/notification'
import { toast } from '~/main'
import { authState, getToken } from '~/utils/auth'
import { stripMarkdownLength } from '~/utils/markdown'
import {
fetchNotifications,
fetchUnreadCount,
isLoadingMessage,
markRead,
notifications,
markAllRead,
} from '~/utils/notification'
import TimeManager from '~/utils/time'
import { reactionEmojiMap } from '~/utils/reactions'
export default {
name: 'MessagePageView',
components: { BaseTimeline, BasePlaceholder, NotificationContainer },
setup() {
const router = useRouter()
const route = useRoute()
const notifications = ref([])
const isLoadingMessage = ref(false)
const selectedTab = ref(
['all', 'unread', 'control'].includes(route.query.tab) ? route.query.tab : 'unread',
)
const notificationPrefs = ref([])
const filteredNotifications = computed(() =>
selectedTab.value === 'all'
? notifications.value
: notifications.value.filter((n) => !n.read),
)
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
const route = useRoute()
const selectedTab = ref(
['all', 'unread', 'control'].includes(route.query.tab) ? route.query.tab : 'unread',
)
const notificationPrefs = ref([])
const filteredNotifications = computed(() =>
selectedTab.value === 'all' ? notifications.value : notifications.value.filter((n) => !n.read),
)
const markRead = async (id) => {
if (!id) return
const n = notifications.value.find((n) => n.id === id)
if (!n || n.read) return
n.read = true
if (notificationState.unreadCount > 0) notificationState.unreadCount--
const ok = await markNotificationsRead([id])
if (!ok) {
n.read = false
notificationState.unreadCount++
} else {
fetchUnreadCount()
}
}
const markAllRead = async () => {
// 除了 REGISTER_REQUEST 类型消息
const idsToMark = notifications.value
.filter((n) => n.type !== 'REGISTER_REQUEST' && !n.read)
.map((n) => n.id)
if (idsToMark.length === 0) return
notifications.value.forEach((n) => {
if (n.type !== 'REGISTER_REQUEST') n.read = true
})
notificationState.unreadCount = notifications.value.filter((n) => !n.read).length
const ok = await markNotificationsRead(idsToMark)
if (!ok) {
notifications.value.forEach((n) => {
if (idsToMark.includes(n.id)) n.read = false
})
await fetchUnreadCount()
return
}
fetchUnreadCount()
if (authState.role === 'ADMIN') {
toast.success('已读所有消息(注册请求除外)')
} else {
toast.success('已读所有消息')
}
}
const iconMap = {
POST_VIEWED: 'fas fa-eye',
COMMENT_REPLY: 'fas fa-reply',
POST_REVIEWED: 'fas fa-shield-alt',
POST_REVIEW_REQUEST: 'fas fa-gavel',
POST_UPDATED: 'fas fa-comment-dots',
USER_ACTIVITY: 'fas fa-user',
FOLLOWED_POST: 'fas fa-feather-alt',
USER_FOLLOWED: 'fas fa-user-plus',
USER_UNFOLLOWED: 'fas fa-user-minus',
POST_SUBSCRIBED: 'fas fa-bookmark',
POST_UNSUBSCRIBED: 'fas fa-bookmark',
REGISTER_REQUEST: 'fas fa-user-clock',
ACTIVITY_REDEEM: 'fas fa-coffee',
MENTION: 'fas fa-at',
}
const fetchNotifications = async () => {
try {
const token = getToken()
if (!token) {
toast.error('请先登录')
return
}
isLoadingMessage.value = true
notifications.value = []
const res = await fetch(`${API_BASE_URL}/api/notifications`, {
headers: {
Authorization: `Bearer ${token}`,
},
})
isLoadingMessage.value = false
if (!res.ok) {
toast.error('获取通知失败')
return
}
const data = await res.json()
for (const n of data) {
if (n.type === 'COMMENT_REPLY') {
notifications.value.push({
...n,
src: n.comment.author.avatar,
iconClick: () => {
markRead(n.id)
router.push(`/users/${n.comment.author.id}`)
},
})
} else if (n.type === 'REACTION') {
notifications.value.push({
...n,
emoji: reactionEmojiMap[n.reactionType],
iconClick: () => {
if (n.fromUser) {
markRead(n.id)
router.push(`/users/${n.fromUser.id}`)
}
},
})
} else if (n.type === 'POST_VIEWED') {
notifications.value.push({
...n,
src: n.fromUser ? n.fromUser.avatar : null,
icon: n.fromUser ? undefined : iconMap[n.type],
iconClick: () => {
if (n.fromUser) {
markRead(n.id)
router.push(`/users/${n.fromUser.id}`)
}
},
})
} else if (n.type === 'POST_UPDATED') {
notifications.value.push({
...n,
src: n.comment.author.avatar,
iconClick: () => {
markRead(n.id)
router.push(`/users/${n.comment.author.id}`)
},
})
} else if (n.type === 'USER_ACTIVITY') {
notifications.value.push({
...n,
src: n.comment.author.avatar,
iconClick: () => {
markRead(n.id)
router.push(`/users/${n.comment.author.id}`)
},
})
} else if (n.type === 'MENTION') {
notifications.value.push({
...n,
icon: iconMap[n.type],
iconClick: () => {
if (n.fromUser) {
markRead(n.id)
router.push(`/users/${n.fromUser.id}`)
}
},
})
} else if (n.type === 'USER_FOLLOWED' || n.type === 'USER_UNFOLLOWED') {
notifications.value.push({
...n,
icon: iconMap[n.type],
iconClick: () => {
if (n.fromUser) {
markRead(n.id)
router.push(`/users/${n.fromUser.id}`)
}
},
})
} else if (n.type === 'FOLLOWED_POST') {
notifications.value.push({
...n,
icon: iconMap[n.type],
iconClick: () => {
if (n.post) {
markRead(n.id)
router.push(`/posts/${n.post.id}`)
}
},
})
} else if (n.type === 'POST_SUBSCRIBED' || n.type === 'POST_UNSUBSCRIBED') {
notifications.value.push({
...n,
icon: iconMap[n.type],
iconClick: () => {
if (n.post) {
markRead(n.id)
router.push(`/posts/${n.post.id}`)
}
},
})
} else if (n.type === 'POST_REVIEW_REQUEST') {
notifications.value.push({
...n,
src: n.fromUser ? n.fromUser.avatar : null,
icon: n.fromUser ? undefined : iconMap[n.type],
iconClick: () => {
if (n.post) {
markRead(n.id)
router.push(`/posts/${n.post.id}`)
}
},
})
} else if (n.type === 'REGISTER_REQUEST') {
notifications.value.push({
...n,
icon: iconMap[n.type],
iconClick: () => {},
})
} else {
notifications.value.push({
...n,
icon: iconMap[n.type],
})
}
}
} catch (e) {
console.error(e)
}
}
const fetchPrefs = async () => {
notificationPrefs.value = await fetchNotificationPreferences()
}
const togglePref = async (pref) => {
const ok = await updateNotificationPreference(pref.type, !pref.enabled)
if (ok) {
pref.enabled = !pref.enabled
await fetchNotifications()
await fetchUnreadCount()
} else {
toast.error('操作失败')
}
}
const approve = async (id, nid) => {
const token = getToken()
if (!token) return
const res = await fetch(`${API_BASE_URL}/api/admin/users/${id}/approve`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
})
if (res.ok) {
markRead(nid)
toast.success('已同意')
} else {
toast.error('操作失败')
}
}
const reject = async (id, nid) => {
const token = getToken()
if (!token) return
const res = await fetch(`${API_BASE_URL}/api/admin/users/${id}/reject`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
})
if (res.ok) {
markRead(nid)
toast.success('已拒绝')
} else {
toast.error('操作失败')
}
}
const formatType = (t) => {
switch (t) {
case 'POST_VIEWED':
return '帖子被查看'
case 'COMMENT_REPLY':
return '有人回复了你'
case 'REACTION':
return '有人点赞'
case 'POST_REVIEW_REQUEST':
return '帖子待审核'
case 'POST_REVIEWED':
return '帖子审核结果'
case 'POST_UPDATED':
return '关注的帖子有新评论'
case 'FOLLOWED_POST':
return '关注的用户发布了新文章'
case 'POST_SUBSCRIBED':
return '有人订阅了你的文章'
case 'POST_UNSUBSCRIBED':
return '有人取消订阅你的文章'
case 'USER_FOLLOWED':
return '有人关注了你'
case 'USER_UNFOLLOWED':
return '有人取消关注你'
case 'USER_ACTIVITY':
return '关注的用户有新动态'
case 'MENTION':
return '有人提到了你'
case 'REGISTER_REQUEST':
return '有人申请注册'
case 'ACTIVITY_REDEEM':
return '有人申请兑换奶茶'
default:
return t
}
}
onMounted(() => {
fetchNotifications()
fetchPrefs()
})
return {
notifications,
formatType,
isLoadingMessage,
stripMarkdownLength,
markRead,
approve,
reject,
TimeManager,
selectedTab,
filteredNotifications,
markAllRead,
authState,
notificationPrefs,
togglePref,
}
},
const fetchPrefs = async () => {
notificationPrefs.value = await fetchNotificationPreferences()
}
const togglePref = async (pref) => {
const ok = await updateNotificationPreference(pref.type, !pref.enabled)
if (ok) {
pref.enabled = !pref.enabled
await fetchNotifications()
await fetchUnreadCount()
} else {
toast.error('操作失败')
}
}
const approve = async (id, nid) => {
const token = getToken()
if (!token) return
const res = await fetch(`${API_BASE_URL}/api/admin/users/${id}/approve`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
})
if (res.ok) {
markRead(nid)
toast.success('已同意')
} else {
toast.error('操作失败')
}
}
const reject = async (id, nid) => {
const token = getToken()
if (!token) return
const res = await fetch(`${API_BASE_URL}/api/admin/users/${id}/reject`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
})
if (res.ok) {
markRead(nid)
toast.success('已拒绝')
} else {
toast.error('操作失败')
}
}
const formatType = (t) => {
switch (t) {
case 'POST_VIEWED':
return '帖子被查看'
case 'COMMENT_REPLY':
return '有人回复了你'
case 'REACTION':
return '有人点赞'
case 'POST_REVIEW_REQUEST':
return '帖子待审核'
case 'POST_REVIEWED':
return '帖子审核结果'
case 'POST_UPDATED':
return '关注的帖子有新评论'
case 'FOLLOWED_POST':
return '关注的用户发布了新文章'
case 'POST_SUBSCRIBED':
return '有人订阅了你的文章'
case 'POST_UNSUBSCRIBED':
return '有人取消订阅你的文章'
case 'USER_FOLLOWED':
return '有人关注了你'
case 'USER_UNFOLLOWED':
return '有人取消关注你'
case 'USER_ACTIVITY':
return '关注的用户有新动态'
case 'MENTION':
return '有人提到了你'
case 'REGISTER_REQUEST':
return '有人申请注册'
case 'ACTIVITY_REDEEM':
return '有人申请兑换奶茶'
case 'LOTTERY_WIN':
return '抽奖中奖了'
case 'LOTTERY_DRAW':
return '抽奖已开奖'
default:
return t
}
}
onActivated(() => {
fetchNotifications()
fetchPrefs()
})
</script>
<style scoped>

View File

@@ -77,7 +77,7 @@
</div>
</template>
<script>
<script setup>
import 'flatpickr/dist/flatpickr.css'
import { computed, onMounted, ref, watch } from 'vue'
import FlatPickr from 'vue-flatpickr-component'
@@ -88,335 +88,299 @@ import LoginOverlay from '~/components/LoginOverlay.vue'
import PostEditor from '~/components/PostEditor.vue'
import PostTypeSelect from '~/components/PostTypeSelect.vue'
import TagSelect from '~/components/TagSelect.vue'
import { API_BASE_URL, toast } from '~/main'
import { toast } from '~/main'
import { authState, getToken } from '~/utils/auth'
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
export default {
name: 'NewPostPageView',
components: {
PostEditor,
CategorySelect,
TagSelect,
LoginOverlay,
PostTypeSelect,
AvatarCropper,
FlatPickr,
},
setup() {
const title = ref('')
const content = ref('')
const selectedCategory = ref('')
const selectedTags = ref([])
const postType = ref('NORMAL')
const prizeIcon = ref('')
const prizeIconFile = ref(null)
const tempPrizeIcon = ref('')
const showPrizeCropper = ref(false)
const prizeName = ref('')
const prizeCount = ref(1)
const prizeDescription = ref('')
const endTime = ref(null)
const startTime = ref(null)
const dateConfig = { enableTime: true, time_24hr: true, dateFormat: 'Y-m-d H:i' }
const isWaitingPosting = ref(false)
const isAiLoading = ref(false)
const isLogin = computed(() => authState.loggedIn)
const title = ref('')
const content = ref('')
const selectedCategory = ref('')
const selectedTags = ref([])
const postType = ref('NORMAL')
const prizeIcon = ref('')
const prizeIconFile = ref(null)
const tempPrizeIcon = ref('')
const showPrizeCropper = ref(false)
const prizeName = ref('')
const prizeCount = ref(1)
const prizeDescription = ref('')
const endTime = ref(null)
const startTime = ref(null)
const dateConfig = { enableTime: true, time_24hr: true, dateFormat: 'Y-m-d H:i' }
const isWaitingPosting = ref(false)
const isAiLoading = ref(false)
const isLogin = computed(() => authState.loggedIn)
const onPrizeIconChange = (e) => {
const file = e.target.files[0]
if (file) {
const reader = new FileReader()
reader.onload = () => {
tempPrizeIcon.value = reader.result
showPrizeCropper.value = true
}
reader.readAsDataURL(file)
}
const onPrizeIconChange = (e) => {
const file = e.target.files[0]
if (file) {
const reader = new FileReader()
reader.onload = () => {
tempPrizeIcon.value = reader.result
showPrizeCropper.value = true
}
reader.readAsDataURL(file)
}
}
const onPrizeCropped = ({ file, url }) => {
prizeIconFile.value = file
prizeIcon.value = url
}
const onPrizeCropped = ({ file, url }) => {
prizeIconFile.value = file
prizeIcon.value = url
}
watch(prizeCount, (val) => {
if (!val || val < 1) prizeCount.value = 1
watch(prizeCount, (val) => {
if (!val || val < 1) prizeCount.value = 1
})
const loadDraft = async () => {
const token = getToken()
if (!token) return
try {
const res = await fetch(`${API_BASE_URL}/api/drafts/me`, {
headers: { Authorization: `Bearer ${token}` },
})
if (res.ok && res.status !== 204) {
const data = await res.json()
title.value = data.title || ''
content.value = data.content || ''
selectedCategory.value = data.categoryId || ''
selectedTags.value = data.tagIds || []
const loadDraft = async () => {
const token = getToken()
if (!token) return
try {
const res = await fetch(`${API_BASE_URL}/api/drafts/me`, {
headers: { Authorization: `Bearer ${token}` },
})
if (res.ok && res.status !== 204) {
const data = await res.json()
title.value = data.title || ''
content.value = data.content || ''
selectedCategory.value = data.categoryId || ''
selectedTags.value = data.tagIds || []
toast.success('草稿已加载')
}
} catch (e) {
console.error(e)
}
toast.success('草稿已加载')
}
} catch (e) {
console.error(e)
}
}
onMounted(loadDraft)
onMounted(loadDraft)
const clearPost = async () => {
title.value = ''
content.value = ''
selectedCategory.value = ''
selectedTags.value = []
postType.value = 'NORMAL'
prizeIcon.value = ''
prizeIconFile.value = null
tempPrizeIcon.value = ''
showPrizeCropper.value = false
prizeDescription.value = ''
prizeCount.value = 1
endTime.value = null
startTime.value = null
const clearPost = async () => {
title.value = ''
content.value = ''
selectedCategory.value = ''
selectedTags.value = []
postType.value = 'NORMAL'
prizeIcon.value = ''
prizeIconFile.value = null
tempPrizeIcon.value = ''
showPrizeCropper.value = false
prizeDescription.value = ''
prizeCount.value = 1
endTime.value = null
startTime.value = null
// 删除草稿
const token = getToken()
if (token) {
const res = await fetch(`${API_BASE_URL}/api/drafts/me`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${token}`,
},
})
if (res.ok) {
toast.success('草稿已清空')
} else {
toast.error('云端草稿清空失败, 请稍后重试')
}
}
// 删除草稿
const token = getToken()
if (token) {
const res = await fetch(`${API_BASE_URL}/api/drafts/me`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${token}`,
},
})
if (res.ok) {
toast.success('草稿已清空')
} else {
toast.error('云端草稿清空失败, 请稍后重试')
}
}
}
const saveDraft = async () => {
const token = getToken()
if (!token) {
toast.error('请先登录')
return
}
try {
const tagIds = selectedTags.value.filter((t) => typeof t === 'number')
const res = await fetch(`${API_BASE_URL}/api/drafts`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
title: title.value,
content: content.value,
categoryId: selectedCategory.value || null,
tagIds,
}),
})
if (res.ok) {
toast.success('草稿已保存')
} else {
toast.error('保存失败')
}
} catch (e) {
toast.error('保存失败')
}
const saveDraft = async () => {
const token = getToken()
if (!token) {
toast.error('请先登录')
return
}
try {
const tagIds = selectedTags.value.filter((t) => typeof t === 'number')
const res = await fetch(`${API_BASE_URL}/api/drafts`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
title: title.value,
content: content.value,
categoryId: selectedCategory.value || null,
tagIds,
}),
})
if (res.ok) {
toast.success('草稿已保存')
} else {
toast.error('保存失败')
}
const ensureTags = async (token) => {
for (let i = 0; i < selectedTags.value.length; i++) {
const t = selectedTags.value[i]
if (typeof t === 'string' && t.startsWith('__new__:')) {
const name = t.slice(8)
const res = await fetch(`${API_BASE_URL}/api/tags`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ name, description: '' }),
})
if (res.ok) {
const data = await res.json()
selectedTags.value[i] = data.id
// update local TagSelect options handled by component
} else {
let data
try {
data = await res.json()
} catch (e) {
data = null
}
toast.error((data && data.error) || '创建标签失败')
throw new Error('create tag failed')
}
}
}
}
const aiGenerate = async () => {
if (!content.value.trim()) {
toast.error('内容为空,无法优化')
return
}
isAiLoading.value = true
try {
toast.info('AI 优化中...')
const token = getToken()
const res = await fetch(`${API_BASE_URL}/api/ai/format`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ text: content.value }),
})
if (res.ok) {
const data = await res.json()
content.value = data.content || ''
} else if (res.status === 429) {
toast.error('今日AI优化次数已用尽')
} else {
toast.error('AI 优化失败')
}
} catch (e) {
toast.error('AI 优化失败')
} finally {
isAiLoading.value = false
}
}
const submitPost = async () => {
if (!title.value.trim()) {
toast.error('标题不能为空')
return
}
if (!content.value.trim()) {
toast.error('内容不能为空')
return
}
if (!selectedCategory.value) {
toast.error('请选择分类')
return
}
if (selectedTags.value.length === 0) {
toast.error('请选择标签')
return
}
if (postType.value === 'LOTTERY') {
if (!prizeIcon.value) {
toast.error('请上传奖品图片')
return
}
if (!prizeCount.value || prizeCount.value < 1) {
toast.error('奖品数量必须大于0')
return
}
if (!prizeDescription.value) {
toast.error('请输入奖品描述')
return
}
if (!endTime.value) {
toast.error('请选择抽奖结束时间')
return
}
}
try {
const token = getToken()
await ensureTags(token)
isWaitingPosting.value = true
let prizeIconUrl = prizeIcon.value
if (postType.value === 'LOTTERY' && prizeIconFile.value) {
const form = new FormData()
form.append('file', prizeIconFile.value)
const uploadRes = await fetch(`${API_BASE_URL}/api/upload`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
body: form,
})
const uploadData = await uploadRes.json()
if (!uploadRes.ok || uploadData.code !== 0) {
toast.error('奖品图片上传失败')
return
}
prizeIconUrl = uploadData.data.url
}
const res = await fetch(`${API_BASE_URL}/api/posts`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
title: title.value,
content: content.value,
categoryId: selectedCategory.value,
tagIds: selectedTags.value,
type: postType.value,
prizeIcon: postType.value === 'LOTTERY' ? prizeIconUrl : undefined,
prizeName: postType.value === 'LOTTERY' ? prizeName.value : undefined,
prizeCount: postType.value === 'LOTTERY' ? prizeCount.value : undefined,
prizeDescription: postType.value === 'LOTTERY' ? prizeDescription.value : undefined,
startTime:
postType.value === 'LOTTERY' ? new Date(startTime.value).toISOString() : undefined,
// 将时间转换为 UTC+8.5 时区 todo: 需要优化
endTime:
postType.value === 'LOTTERY'
? new Date(new Date(endTime.value).getTime() + 8.02 * 60 * 60 * 1000).toISOString()
: undefined,
}),
})
} catch (e) {
toast.error('保存失败')
}
}
const ensureTags = async (token) => {
for (let i = 0; i < selectedTags.value.length; i++) {
const t = selectedTags.value[i]
if (typeof t === 'string' && t.startsWith('__new__:')) {
const name = t.slice(8)
const res = await fetch(`${API_BASE_URL}/api/tags`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ name, description: '' }),
})
if (res.ok) {
const data = await res.json()
if (res.ok) {
if (data.reward && data.reward > 0) {
toast.success(`发布成功,获得 ${data.reward} 经验值`)
} else {
toast.success('发布成功')
}
if (data.id) {
window.location.href = `/posts/${data.id}`
}
} else if (res.status === 429) {
toast.error('发布过于频繁,请稍后再试')
} else {
toast.error(data.error || '发布失败')
selectedTags.value[i] = data.id
// update local TagSelect options handled by component
} else {
let data
try {
data = await res.json()
} catch (e) {
data = null
}
} catch (e) {
toast.error('发布失败')
} finally {
isWaitingPosting.value = false
toast.error((data && data.error) || '创建标签失败')
throw new Error('create tag failed')
}
}
return {
title,
content,
selectedCategory,
selectedTags,
postType,
prizeIcon,
prizeCount,
endTime,
submitPost,
saveDraft,
clearPost,
isWaitingPosting,
aiGenerate,
isAiLoading,
isLogin,
onPrizeIconChange,
onPrizeCropped,
showPrizeCropper,
tempPrizeIcon,
dateConfig,
prizeName,
prizeDescription,
}
}
const aiGenerate = async () => {
if (!content.value.trim()) {
toast.error('内容为空,无法优化')
return
}
isAiLoading.value = true
try {
toast.info('AI 优化中...')
const token = getToken()
const res = await fetch(`${API_BASE_URL}/api/ai/format`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ text: content.value }),
})
if (res.ok) {
const data = await res.json()
content.value = data.content || ''
} else if (res.status === 429) {
toast.error('今日AI优化次数已用尽')
} else {
toast.error('AI 优化失败')
}
},
} catch (e) {
toast.error('AI 优化失败')
} finally {
isAiLoading.value = false
}
}
const submitPost = async () => {
if (!title.value.trim()) {
toast.error('标题不能为空')
return
}
if (!content.value.trim()) {
toast.error('内容不能为空')
return
}
if (!selectedCategory.value) {
toast.error('请选择分类')
return
}
if (selectedTags.value.length === 0) {
toast.error('请选择标签')
return
}
if (postType.value === 'LOTTERY') {
if (!prizeIcon.value) {
toast.error('请上传奖品图片')
return
}
if (!prizeCount.value || prizeCount.value < 1) {
toast.error('奖品数量必须大于0')
return
}
if (!prizeDescription.value) {
toast.error('请输入奖品描述')
return
}
if (!endTime.value) {
toast.error('请选择抽奖结束时间')
return
}
}
try {
const token = getToken()
await ensureTags(token)
isWaitingPosting.value = true
let prizeIconUrl = prizeIcon.value
if (postType.value === 'LOTTERY' && prizeIconFile.value) {
const form = new FormData()
form.append('file', prizeIconFile.value)
const uploadRes = await fetch(`${API_BASE_URL}/api/upload`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
body: form,
})
const uploadData = await uploadRes.json()
if (!uploadRes.ok || uploadData.code !== 0) {
toast.error('奖品图片上传失败')
return
}
prizeIconUrl = uploadData.data.url
}
const res = await fetch(`${API_BASE_URL}/api/posts`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
title: title.value,
content: content.value,
categoryId: selectedCategory.value,
tagIds: selectedTags.value,
type: postType.value,
prizeIcon: postType.value === 'LOTTERY' ? prizeIconUrl : undefined,
prizeName: postType.value === 'LOTTERY' ? prizeName.value : undefined,
prizeCount: postType.value === 'LOTTERY' ? prizeCount.value : undefined,
prizeDescription: postType.value === 'LOTTERY' ? prizeDescription.value : undefined,
startTime:
postType.value === 'LOTTERY' ? new Date(startTime.value).toISOString() : undefined,
// 将时间转换为 UTC+8.5 时区 todo: 需要优化
endTime:
postType.value === 'LOTTERY'
? new Date(new Date(endTime.value).getTime() + 8.02 * 60 * 60 * 1000).toISOString()
: undefined,
}),
})
const data = await res.json()
if (res.ok) {
if (data.reward && data.reward > 0) {
toast.success(`发布成功,获得 ${data.reward} 经验值`)
} else {
toast.success('发布成功')
}
if (data.id) {
window.location.href = `/posts/${data.id}`
}
} else if (res.status === 429) {
toast.error('发布过于频繁,请稍后再试')
} else {
toast.error(data.error || '发布失败')
}
} catch (e) {
toast.error('发布失败')
} finally {
isWaitingPosting.value = false
}
}
</script>

View File

@@ -35,186 +35,168 @@
</div>
</template>
<script>
<script setup>
import { ref, onMounted, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useRoute } from 'vue-router'
import PostEditor from '~/components/PostEditor.vue'
import CategorySelect from '~/components/CategorySelect.vue'
import TagSelect from '~/components/TagSelect.vue'
import { API_BASE_URL, toast } from '~/main'
import { toast } from '~/main'
import { getToken, authState } from '~/utils/auth'
import LoginOverlay from '~/components/LoginOverlay.vue'
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
export default {
name: 'EditPostPageView',
components: { PostEditor, CategorySelect, TagSelect, LoginOverlay },
setup() {
const title = ref('')
const content = ref('')
const selectedCategory = ref('')
const selectedTags = ref([])
const isWaitingPosting = ref(false)
const isAiLoading = ref(false)
const isLogin = computed(() => authState.loggedIn)
const title = ref('')
const content = ref('')
const selectedCategory = ref('')
const selectedTags = ref([])
const isWaitingPosting = ref(false)
const isAiLoading = ref(false)
const isLogin = computed(() => authState.loggedIn)
const route = useRoute()
const router = useRouter()
const postId = route.params.id
const route = useRoute()
const postId = route.params.id
const loadPost = async () => {
try {
const token = getToken()
const res = await fetch(`${API_BASE_URL}/api/posts/${postId}`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
})
if (res.ok) {
const data = await res.json()
title.value = data.title || ''
content.value = data.content || ''
selectedCategory.value = data.category.id || ''
selectedTags.value = (data.tags || []).map((t) => t.id)
}
} catch (e) {
toast.error('加载失败')
}
const loadPost = async () => {
try {
const token = getToken()
const res = await fetch(`${API_BASE_URL}/api/posts/${postId}`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
})
if (res.ok) {
const data = await res.json()
title.value = data.title || ''
content.value = data.content || ''
selectedCategory.value = data.category.id || ''
selectedTags.value = (data.tags || []).map((t) => t.id)
}
} catch (e) {
toast.error('加载失败')
}
}
onMounted(loadPost)
onMounted(loadPost)
const clearPost = () => {
title.value = ''
content.value = ''
selectedCategory.value = ''
selectedTags.value = []
}
const clearPost = () => {
title.value = ''
content.value = ''
selectedCategory.value = ''
selectedTags.value = []
}
const ensureTags = async (token) => {
for (let i = 0; i < selectedTags.value.length; i++) {
const t = selectedTags.value[i]
if (typeof t === 'string' && t.startsWith('__new__:')) {
const name = t.slice(8)
const res = await fetch(`${API_BASE_URL}/api/tags`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ name, description: '' }),
})
if (res.ok) {
const data = await res.json()
selectedTags.value[i] = data.id
// update local TagSelect options handled by component
} else {
let data
try {
data = await res.json()
} catch (e) {
data = null
}
toast.error((data && data.error) || '创建标签失败')
throw new Error('create tag failed')
}
}
}
}
const aiGenerate = async () => {
if (!content.value.trim()) {
toast.error('内容为空,无法优化')
return
}
isAiLoading.value = true
try {
toast.info('AI 优化中...')
const token = getToken()
const res = await fetch(`${API_BASE_URL}/api/ai/format`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ text: content.value }),
})
if (res.ok) {
const data = await res.json()
content.value = data.content || ''
} else if (res.status === 429) {
toast.error('今日AI优化次数已用尽')
} else {
toast.error('AI 优化失败')
}
} catch (e) {
toast.error('AI 优化失败')
} finally {
isAiLoading.value = false
}
}
const submitPost = async () => {
if (!title.value.trim()) {
toast.error('标题不能为空')
return
}
if (!content.value.trim()) {
toast.error('内容不能为空')
return
}
if (!selectedCategory.value) {
toast.error('请选择分类')
return
}
if (selectedTags.value.length === 0) {
toast.error('请选择标签')
return
}
try {
const token = getToken()
await ensureTags(token)
isWaitingPosting.value = true
const res = await fetch(`${API_BASE_URL}/api/posts/${postId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
title: title.value,
content: content.value,
categoryId: selectedCategory.value,
tagIds: selectedTags.value,
}),
})
const ensureTags = async (token) => {
for (let i = 0; i < selectedTags.value.length; i++) {
const t = selectedTags.value[i]
if (typeof t === 'string' && t.startsWith('__new__:')) {
const name = t.slice(8)
const res = await fetch(`${API_BASE_URL}/api/tags`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ name, description: '' }),
})
if (res.ok) {
const data = await res.json()
if (res.ok) {
toast.success('更新成功')
window.location.href = `/posts/${postId}`
} else {
toast.error(data.error || '更新失败')
selectedTags.value[i] = data.id
// update local TagSelect options handled by component
} else {
let data
try {
data = await res.json()
} catch (e) {
data = null
}
} catch (e) {
toast.error('更新失败')
} finally {
isWaitingPosting.value = false
toast.error((data && data.error) || '创建标签失败')
throw new Error('create tag failed')
}
}
const cancelEdit = () => {
router.push(`/posts/${postId}`)
}
}
const aiGenerate = async () => {
if (!content.value.trim()) {
toast.error('内容为空,无法优化')
return
}
isAiLoading.value = true
try {
toast.info('AI 优化中...')
const token = getToken()
const res = await fetch(`${API_BASE_URL}/api/ai/format`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ text: content.value }),
})
if (res.ok) {
const data = await res.json()
content.value = data.content || ''
} else if (res.status === 429) {
toast.error('今日AI优化次数已用尽')
} else {
toast.error('AI 优化失败')
}
return {
title,
content,
selectedCategory,
selectedTags,
submitPost,
clearPost,
cancelEdit,
isWaitingPosting,
aiGenerate,
isAiLoading,
isLogin,
} catch (e) {
toast.error('AI 优化失败')
} finally {
isAiLoading.value = false
}
}
const submitPost = async () => {
if (!title.value.trim()) {
toast.error('标题不能为空')
return
}
if (!content.value.trim()) {
toast.error('内容不能为空')
return
}
if (!selectedCategory.value) {
toast.error('请选择分类')
return
}
if (selectedTags.value.length === 0) {
toast.error('请选择标签')
return
}
try {
const token = getToken()
await ensureTags(token)
isWaitingPosting.value = true
const res = await fetch(`${API_BASE_URL}/api/posts/${postId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
title: title.value,
content: content.value,
categoryId: selectedCategory.value,
tagIds: selectedTags.value,
}),
})
const data = await res.json()
if (res.ok) {
toast.success('更新成功')
window.location.href = `/posts/${postId}`
} else {
toast.error(data.error || '更新失败')
}
},
} catch (e) {
toast.error('更新失败')
} finally {
isWaitingPosting.value = false
}
}
const cancelEdit = () => {
navigateTo(`/posts/${postId}`, { replace: true })
}
</script>

View File

File diff suppressed because it is too large Load Diff

View File

@@ -64,173 +64,168 @@
</div>
</template>
<script>
<script setup>
import { ref, onMounted } from 'vue'
import AvatarCropper from '~/components/AvatarCropper.vue'
import BaseInput from '~/components/BaseInput.vue'
import Dropdown from '~/components/Dropdown.vue'
import { API_BASE_URL, toast } from '~/main'
import { toast } from '~/main'
import { fetchCurrentUser, getToken, setToken } from '~/utils/auth'
export default {
name: 'SettingsPageView',
components: { BaseInput, Dropdown, AvatarCropper },
data() {
return {
username: '',
introduction: '',
usernameError: '',
avatar: '',
avatarFile: null,
tempAvatar: '',
showCropper: false,
role: '',
publishMode: 'DIRECT',
passwordStrength: 'LOW',
aiFormatLimit: 3,
registerMode: 'DIRECT',
isLoadingPage: false,
isSaving: false,
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
const username = ref('')
const introduction = ref('')
const usernameError = ref('')
const avatar = ref('')
const avatarFile = ref(null)
const tempAvatar = ref('')
const showCropper = ref(false)
const role = ref('')
const publishMode = ref('DIRECT')
const passwordStrength = ref('LOW')
const aiFormatLimit = ref(3)
const registerMode = ref('DIRECT')
const isLoadingPage = ref(false)
const isSaving = ref(false)
onMounted(async () => {
isLoadingPage.value = true
const user = await fetchCurrentUser()
if (user) {
username.value = user.username
introduction.value = user.introduction || ''
avatar.value = user.avatar
role.value = user.role
if (role.value === 'ADMIN') {
loadAdminConfig()
}
},
async mounted() {
this.isLoadingPage = true
const user = await fetchCurrentUser()
} else {
toast.error('请先登录')
navigateTo('/login', { replace: true })
}
isLoadingPage.value = false
})
if (user) {
this.username = user.username
this.introduction = user.introduction || ''
this.avatar = user.avatar
this.role = user.role
if (this.role === 'ADMIN') {
this.loadAdminConfig()
}
} else {
toast.error('请先登录')
this.$router.push('/login')
const onAvatarChange = (e) => {
const file = e.target.files[0]
if (file) {
const reader = new FileReader()
reader.onload = () => {
tempAvatar.value = reader.result
showCropper.value = true
}
this.isLoadingPage = false
},
methods: {
onAvatarChange(e) {
const file = e.target.files[0]
if (file) {
const reader = new FileReader()
reader.onload = () => {
this.tempAvatar = reader.result
this.showCropper = true
}
reader.readAsDataURL(file)
reader.readAsDataURL(file)
}
}
const onCropped = ({ file, url }) => {
avatarFile.value = file
avatar.value = url
}
const fetchPublishModes = () => {
return Promise.resolve([
{ id: 'DIRECT', name: '直接发布', icon: 'fas fa-bolt' },
{ id: 'REVIEW', name: '审核后发布', icon: 'fas fa-search' },
])
}
const fetchPasswordStrengths = () => {
return Promise.resolve([
{ id: 'LOW', name: '低', icon: 'fas fa-lock-open' },
{ id: 'MEDIUM', name: '中', icon: 'fas fa-lock' },
{ id: 'HIGH', name: '高', icon: 'fas fa-user-shield' },
])
}
const fetchAiLimits = () => {
return Promise.resolve([
{ id: 3, name: '3次' },
{ id: 5, name: '5次' },
{ id: 10, name: '10次' },
{ id: -1, name: '无限' },
])
}
const fetchRegisterModes = () => {
return Promise.resolve([
{ id: 'DIRECT', name: '直接注册', icon: 'fas fa-user-check' },
{ id: 'WHITELIST', name: '白名单邀请制', icon: 'fas fa-envelope' },
])
}
const loadAdminConfig = async () => {
try {
const token = getToken()
const res = await fetch(`${API_BASE_URL}/api/admin/config`, {
headers: { Authorization: `Bearer ${token}` },
})
if (res.ok) {
const data = await res.json()
publishMode.value = data.publishMode
passwordStrength.value = data.passwordStrength
aiFormatLimit.value = data.aiFormatLimit
registerMode.value = data.registerMode
}
} catch (e) {
// ignore
}
}
const save = async () => {
isSaving.value = true
do {
let token = getToken()
usernameError.value = ''
if (!username.value) {
usernameError.value = '用户名不能为空'
}
if (usernameError.value) {
toast.error(usernameError.value)
break
}
if (avatarFile.value) {
const form = new FormData()
form.append('file', avatarFile.value)
const res = await fetch(`${API_BASE_URL}/api/users/me/avatar`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
body: form,
})
const data = await res.json()
if (res.ok) {
avatar.value = data.url
} else {
toast.error(data.error || '上传失败')
break
}
},
onCropped({ file, url }) {
this.avatarFile = file
this.avatar = url
},
fetchPublishModes() {
return Promise.resolve([
{ id: 'DIRECT', name: '直接发布', icon: 'fas fa-bolt' },
{ id: 'REVIEW', name: '审核后发布', icon: 'fas fa-search' },
])
},
fetchPasswordStrengths() {
return Promise.resolve([
{ id: 'LOW', name: '低', icon: 'fas fa-lock-open' },
{ id: 'MEDIUM', name: '中', icon: 'fas fa-lock' },
{ id: 'HIGH', name: '高', icon: 'fas fa-user-shield' },
])
},
fetchAiLimits() {
return Promise.resolve([
{ id: 3, name: '3次' },
{ id: 5, name: '5次' },
{ id: 10, name: '10次' },
{ id: -1, name: '无限' },
])
},
fetchRegisterModes() {
return Promise.resolve([
{ id: 'DIRECT', name: '直接注册', icon: 'fas fa-user-check' },
{ id: 'WHITELIST', name: '白名单邀请制', icon: 'fas fa-envelope' },
])
},
async loadAdminConfig() {
try {
const token = getToken()
const res = await fetch(`${API_BASE_URL}/api/admin/config`, {
headers: { Authorization: `Bearer ${token}` },
})
if (res.ok) {
const data = await res.json()
this.publishMode = data.publishMode
this.passwordStrength = data.passwordStrength
this.aiFormatLimit = data.aiFormatLimit
this.registerMode = data.registerMode
}
} catch (e) {
// ignore
}
},
async save() {
this.isSaving = true
}
const res = await fetch(`${API_BASE_URL}/api/users/me`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({ username: username.value, introduction: introduction.value }),
})
do {
let token = getToken()
this.usernameError = ''
if (!this.username) {
this.usernameError = '用户名不能为空'
}
if (this.usernameError) {
toast.error(this.usernameError)
break
}
if (this.avatarFile) {
const form = new FormData()
form.append('file', this.avatarFile)
const res = await fetch(`${API_BASE_URL}/api/users/me/avatar`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
body: form,
})
const data = await res.json()
if (res.ok) {
this.avatar = data.url
} else {
toast.error(data.error || '上传失败')
break
}
}
const res = await fetch(`${API_BASE_URL}/api/users/me`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({ username: this.username, introduction: this.introduction }),
})
const data = await res.json()
if (!res.ok) {
toast.error(data.error || '保存失败')
break
}
if (data.token) {
setToken(data.token)
token = data.token
}
if (role.value === 'ADMIN') {
await fetch(`${API_BASE_URL}/api/admin/config`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({
publishMode: publishMode.value,
passwordStrength: passwordStrength.value,
aiFormatLimit: aiFormatLimit.value,
registerMode: registerMode.value,
}),
})
}
toast.success('保存成功')
} while (!isSaving.value)
const data = await res.json()
if (!res.ok) {
toast.error(data.error || '保存失败')
break
}
if (data.token) {
setToken(data.token)
token = data.token
}
if (this.role === 'ADMIN') {
await fetch(`${API_BASE_URL}/api/admin/config`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({
publishMode: this.publishMode,
passwordStrength: this.passwordStrength,
aiFormatLimit: this.aiFormatLimit,
registerMode: this.registerMode,
}),
})
}
toast.success('保存成功')
} while (!this.isSaving)
this.isSaving = false
},
},
isSaving.value = false
}
</script>

View File

@@ -18,63 +18,57 @@
</div>
</template>
<script>
<script setup>
import BaseInput from '~/components/BaseInput.vue'
import { API_BASE_URL, toast } from '~/main'
import { toast } from '~/main'
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
export default {
name: 'SignupReasonPageView',
components: { BaseInput },
data() {
return {
reason: '',
error: '',
isWaitingForRegister: false,
token: '',
}
},
mounted() {
this.token = this.$route.query.token || ''
if (!this.token) {
this.$router.push('/signup')
}
},
methods: {
async submit() {
if (!this.reason || this.reason.trim().length < 20) {
this.error = '请至少输入20个字'
return
}
const reason = ref('')
const error = ref('')
const isWaitingForRegister = ref(false)
const token = ref('')
try {
this.isWaitingForRegister = true
const res = await fetch(`${API_BASE_URL}/api/auth/reason`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
token: this.token,
reason: this.reason,
}),
})
this.isWaitingForRegister = false
const data = await res.json()
if (res.ok) {
toast.success('注册理由已提交,请等待审核')
this.$router.push('/')
} else if (data.reason_code === 'INVALID_CREDENTIALS') {
toast.error('登录已过期,请重新登录')
this.$router.push('/login')
} else {
toast.error(data.error || '提交失败')
}
} catch (e) {
this.isWaitingForRegister = false
toast.error('提交失败')
}
},
},
onMounted(async () => {
token.value = route.query.token || ''
if (!token.value) {
await navigateTo({ path: '/signup' }, { replace: true })
}
})
const submit = async () => {
if (!reason.value || reason.value.trim().length < 20) {
error.value = '请至少输入20个字'
return
}
try {
isWaitingForRegister.value = true
const res = await fetch(`${API_BASE_URL}/api/auth/reason`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
token: this.token,
reason: this.reason,
}),
})
isWaitingForRegister.value = false
const data = await res.json()
if (res.ok) {
toast.success('注册理由已提交,请等待审核')
await navigateTo('/', { replace: true })
} else if (data.reason_code === 'INVALID_CREDENTIALS') {
toast.error('登录已过期,请重新登录')
await navigateTo('/login', { replace: true })
} else {
toast.error(data.error || '提交失败')
}
} catch (e) {
isWaitingForRegister.value = false
toast.error('提交失败')
}
}
</script>

View File

@@ -89,135 +89,128 @@
</div>
</template>
<script>
<script setup>
import BaseInput from '~/components/BaseInput.vue'
import { API_BASE_URL, toast } from '~/main'
import { toast } from '~/main'
import { discordAuthorize } from '~/utils/discord'
import { githubAuthorize } from '~/utils/github'
import { googleAuthorize } from '~/utils/google'
import { twitterAuthorize } from '~/utils/twitter'
export default {
name: 'SignupPageView',
components: { BaseInput },
setup() {
return { googleAuthorize }
},
data() {
return {
emailStep: 0,
email: '',
username: '',
password: '',
registerMode: 'DIRECT',
emailError: '',
usernameError: '',
passwordError: '',
code: '',
isWaitingForEmailSent: false,
isWaitingForEmailVerified: false,
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
const emailStep = ref(0)
const email = ref('')
const username = ref('')
const password = ref('')
const registerMode = ref('DIRECT')
const emailError = ref('')
const usernameError = ref('')
const passwordError = ref('')
const code = ref('')
const isWaitingForEmailSent = ref(false)
const isWaitingForEmailVerified = ref(false)
onMounted(async () => {
username.value = route.query.u || ''
try {
const res = await fetch(`${API_BASE_URL}/api/config`)
if (res.ok) {
const data = await res.json()
registerMode.value = data.registerMode
}
},
async mounted() {
this.username = this.$route.query.u || ''
try {
const res = await fetch(`${API_BASE_URL}/api/config`)
if (res.ok) {
const data = await res.json()
this.registerMode = data.registerMode
}
} catch {
/* ignore */
} catch {
/* ignore */
}
if (route.query.verify) {
emailStep.value = 1
}
})
const clearErrors = () => {
emailError.value = ''
usernameError.value = ''
passwordError.value = ''
}
const sendVerification = async () => {
clearErrors()
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
if (!emailRegex.test(email.value)) {
emailError.value = '邮箱格式不正确'
}
if (!password.value || password.value.length < 6) {
passwordError.value = '密码至少6位'
}
if (!username.value) {
usernameError.value = '用户名不能为空'
}
if (emailError.value || passwordError.value || usernameError.value) {
return
}
try {
isWaitingForEmailSent.value = true
const res = await fetch(`${API_BASE_URL}/api/auth/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: username.value,
email: email.value,
password: password.value,
}),
})
isWaitingForEmailSent.value = false
const data = await res.json()
if (res.ok) {
emailStep.value = 1
toast.success('验证码已发送,请查看邮箱')
} else if (data.field) {
if (data.field === 'username') usernameError.value = data.error
if (data.field === 'email') emailError.value = data.error
if (data.field === 'password') passwordError.value = data.error
} else {
toast.error(data.error || '发送失败')
}
if (this.$route.query.verify) {
this.emailStep = 1
} catch (e) {
toast.error('发送失败')
}
}
const verifyCode = async () => {
try {
isWaitingForEmailVerified.value = true
const res = await fetch(`${API_BASE_URL}/api/auth/verify`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
code: code.value,
username: username.value,
}),
})
const data = await res.json()
if (res.ok) {
if (registerMode.value === 'WHITELIST') {
navigateTo(`/signup-reason?token=${data.token}`, { replace: true })
} else {
toast.success('注册成功,请登录')
navigateTo('/login', { replace: true })
}
} else {
toast.error(data.error || '注册失败')
}
},
methods: {
clearErrors() {
this.emailError = ''
this.usernameError = ''
this.passwordError = ''
},
async sendVerification() {
this.clearErrors()
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
if (!emailRegex.test(this.email)) {
this.emailError = '邮箱格式不正确'
}
if (!this.password || this.password.length < 6) {
this.passwordError = '密码至少6位'
}
if (!this.username) {
this.usernameError = '用户名不能为空'
}
if (this.emailError || this.passwordError || this.usernameError) {
return
}
try {
this.isWaitingForEmailSent = true
const res = await fetch(`${API_BASE_URL}/api/auth/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: this.username,
email: this.email,
password: this.password,
}),
})
this.isWaitingForEmailSent = false
const data = await res.json()
if (res.ok) {
this.emailStep = 1
toast.success('验证码已发送,请查看邮箱')
} else if (data.field) {
if (data.field === 'username') this.usernameError = data.error
if (data.field === 'email') this.emailError = data.error
if (data.field === 'password') this.passwordError = data.error
} else {
toast.error(data.error || '发送失败')
}
} catch (e) {
toast.error('发送失败')
}
},
async verifyCode() {
try {
this.isWaitingForEmailVerified = true
const res = await fetch(`${API_BASE_URL}/api/auth/verify`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
code: this.code,
username: this.username,
}),
})
const data = await res.json()
if (res.ok) {
if (this.registerMode === 'WHITELIST') {
this.$router.push('/signup-reason?token=' + data.token)
} else {
toast.success('注册成功,请登录')
this.$router.push('/login')
}
} else {
toast.error(data.error || '注册失败')
}
} catch (e) {
toast.error('注册失败')
} finally {
this.isWaitingForEmailVerified = false
}
},
signupWithGithub() {
githubAuthorize()
},
signupWithDiscord() {
discordAuthorize()
},
signupWithTwitter() {
twitterAuthorize()
},
},
} catch (e) {
toast.error('注册失败')
} finally {
isWaitingForEmailVerified.value = false
}
}
const signupWithGithub = () => {
githubAuthorize()
}
const signupWithDiscord = () => {
discordAuthorize()
}
const signupWithTwitter = () => {
twitterAuthorize()
}
</script>

View File

@@ -2,24 +2,20 @@
<CallbackPage />
</template>
<script>
<script setup>
import CallbackPage from '~/components/CallbackPage.vue'
import { twitterExchange } from '~/utils/twitter'
export default {
name: 'TwitterCallbackPageView',
components: { CallbackPage },
async mounted() {
const url = new URL(window.location.href)
const code = url.searchParams.get('code')
const state = url.searchParams.get('state')
const result = await twitterExchange(code, state, '')
onMounted(async () => {
const url = new URL(window.location.href)
const code = url.searchParams.get('code')
const state = url.searchParams.get('state')
const result = await twitterExchange(code, state, '')
if (result.needReason) {
this.$router.push('/signup-reason?token=' + result.token)
} else {
this.$router.push('/')
}
},
}
if (result.needReason) {
navigateTo(`/signup-reason?token=${result.token}`, { replace: true })
} else {
navigateTo('/', { replace: true })
}
})
</script>

View File

@@ -296,287 +296,250 @@
</div>
</template>
<script>
import { ref, computed, onMounted, watch } from 'vue'
<script setup>
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { API_BASE_URL, toast } from '~/main'
import { getToken, authState } from '~/utils/auth'
import BaseTimeline from '~/components/BaseTimeline.vue'
import UserList from '~/components/UserList.vue'
import AchievementList from '~/components/AchievementList.vue'
import BasePlaceholder from '~/components/BasePlaceholder.vue'
import BaseTimeline from '~/components/BaseTimeline.vue'
import LevelProgress from '~/components/LevelProgress.vue'
import UserList from '~/components/UserList.vue'
import { toast } from '~/main'
import { authState, getToken } from '~/utils/auth'
import { prevLevelExp } from '~/utils/level'
import { stripMarkdown, stripMarkdownLength } from '~/utils/markdown'
import TimeManager from '~/utils/time'
import { prevLevelExp } from '~/utils/level'
import AchievementList from '~/components/AchievementList.vue'
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
definePageMeta({
alias: ['/users/:id/'],
})
const route = useRoute()
const router = useRouter()
const username = route.params.id
export default {
name: 'ProfileView',
components: { BaseTimeline, UserList, BasePlaceholder, LevelProgress, AchievementList },
setup() {
const route = useRoute()
const router = useRouter()
const username = route.params.id
const user = ref({})
const hotPosts = ref([])
const hotReplies = ref([])
const hotTags = ref([])
const timelineItems = ref([])
const followers = ref([])
const followings = ref([])
const medals = ref([])
const subscribed = ref(false)
const isLoading = ref(true)
const tabLoading = ref(false)
const selectedTab = ref(
['summary', 'timeline', 'following', 'achievements'].includes(route.query.tab)
? route.query.tab
: 'summary',
)
const followTab = ref('followers')
const user = ref({})
const hotPosts = ref([])
const hotReplies = ref([])
const hotTags = ref([])
const timelineItems = ref([])
const followers = ref([])
const followings = ref([])
const medals = ref([])
const subscribed = ref(false)
const isLoading = ref(true)
const tabLoading = ref(false)
const selectedTab = ref(
['summary', 'timeline', 'following', 'achievements'].includes(route.query.tab)
? route.query.tab
: 'summary',
)
const followTab = ref('followers')
const levelInfo = computed(() => {
const exp = user.value.experience || 0
const currentLevel = user.value.currentLevel || 0
const nextExp = user.value.nextLevelExp || 0
const prevExp = prevLevelExp(currentLevel)
const total = nextExp - prevExp
const ratio = total > 0 ? (exp - prevExp) / total : 1
const percent = Math.max(0, Math.min(1, ratio)) * 100
return { exp, currentLevel, nextExp, percent }
})
const levelInfo = computed(() => {
const exp = user.value.experience || 0
const currentLevel = user.value.currentLevel || 0
const nextExp = user.value.nextLevelExp || 0
const prevExp = prevLevelExp(currentLevel)
const total = nextExp - prevExp
const ratio = total > 0 ? (exp - prevExp) / total : 1
const percent = Math.max(0, Math.min(1, ratio)) * 100
return { exp, currentLevel, nextExp, percent }
})
const isMine = computed(function () {
const mine = authState.username === username || String(authState.userId) === username
console.log(mine)
return mine
})
const isMine = computed(function () {
const mine = authState.username === username || String(authState.userId) === username
console.log(mine)
return mine
})
const formatDate = (d) => {
if (!d) return ''
return TimeManager.format(d)
}
const fetchUser = async () => {
const token = getToken()
const headers = token ? { Authorization: `Bearer ${token}` } : {}
const res = await fetch(`${API_BASE_URL}/api/users/${username}`, { headers })
if (res.ok) {
const data = await res.json()
user.value = data
subscribed.value = !!data.subscribed
} else if (res.status === 404) {
router.replace('/404')
}
}
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: 'fas fa-book', post: p }))
}
const repliesRes = await fetch(`${API_BASE_URL}/api/users/${username}/hot-replies`)
if (repliesRes.ok) {
const data = await repliesRes.json()
hotReplies.value = data.map((c) => ({ icon: 'fas fa-comment', comment: c }))
}
const tagsRes = await fetch(`${API_BASE_URL}/api/users/${username}/hot-tags`)
if (tagsRes.ok) {
const data = await tagsRes.json()
hotTags.value = data.map((t) => ({ icon: 'fas fa-tag', tag: t }))
}
}
const fetchTimeline = async () => {
const [postsRes, repliesRes, tagsRes] = await Promise.all([
fetch(`${API_BASE_URL}/api/users/${username}/posts?limit=50`),
fetch(`${API_BASE_URL}/api/users/${username}/replies?limit=50`),
fetch(`${API_BASE_URL}/api/users/${username}/tags?limit=50`),
])
const posts = postsRes.ok ? await postsRes.json() : []
const replies = repliesRes.ok ? await repliesRes.json() : []
const tags = tagsRes.ok ? await tagsRes.json() : []
const mapped = [
...posts.map((p) => ({
type: 'post',
icon: 'fas fa-book',
post: p,
createdAt: p.createdAt,
})),
...replies.map((r) => ({
type: r.parentComment ? 'reply' : 'comment',
icon: 'fas fa-comment',
comment: r,
createdAt: r.createdAt,
})),
...tags.map((t) => ({
type: 'tag',
icon: 'fas fa-tag',
tag: t,
createdAt: t.createdAt,
})),
]
mapped.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
timelineItems.value = mapped
}
const fetchFollowUsers = async () => {
const [followerRes, followingRes] = await Promise.all([
fetch(`${API_BASE_URL}/api/users/${username}/followers`),
fetch(`${API_BASE_URL}/api/users/${username}/following`),
])
followers.value = followerRes.ok ? await followerRes.json() : []
followings.value = followingRes.ok ? await followingRes.json() : []
}
const loadSummary = async () => {
tabLoading.value = true
await fetchSummary()
tabLoading.value = false
}
const loadTimeline = async () => {
tabLoading.value = true
await fetchTimeline()
tabLoading.value = false
}
const loadFollow = async () => {
tabLoading.value = true
await fetchFollowUsers()
tabLoading.value = false
}
const fetchAchievements = async () => {
const res = await fetch(`${API_BASE_URL}/api/medals?userId=${user.value.id}`)
if (res.ok) {
medals.value = await res.json()
} else {
medals.value = []
toast.error('获取成就失败')
}
}
const loadAchievements = async () => {
tabLoading.value = true
await fetchAchievements()
tabLoading.value = false
}
const subscribeUser = async () => {
const token = getToken()
if (!token) {
toast.error('请先登录')
return
}
const res = await fetch(`${API_BASE_URL}/api/subscriptions/users/${username}`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
})
if (res.ok) {
subscribed.value = true
toast.success('已关注')
} else {
toast.error('操作失败')
}
}
const unsubscribeUser = async () => {
const token = getToken()
if (!token) {
toast.error('请先登录')
return
}
const res = await fetch(`${API_BASE_URL}/api/subscriptions/users/${username}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` },
})
if (res.ok) {
subscribed.value = false
toast.success('已取消关注')
} else {
toast.error('操作失败')
}
}
const gotoTag = (tag) => {
const value = encodeURIComponent(tag.id ?? tag.name)
router.push({ path: '/', query: { tags: value } })
}
const init = async () => {
try {
await fetchUser()
if (selectedTab.value === 'summary') {
await loadSummary()
} else if (selectedTab.value === 'timeline') {
await loadTimeline()
} else if (selectedTab.value === 'following') {
await loadFollow()
} else if (selectedTab.value === 'achievements') {
await loadAchievements()
}
} catch (e) {
console.error(e)
} finally {
isLoading.value = false
}
}
onMounted(init)
watch(selectedTab, async (val) => {
// router.replace({ query: { ...route.query, tab: val } })
if (val === 'timeline' && timelineItems.value.length === 0) {
await loadTimeline()
} else if (
val === 'following' &&
followers.value.length === 0 &&
followings.value.length === 0
) {
await loadFollow()
} else if (val === 'achievements' && medals.value.length === 0) {
await loadAchievements()
}
})
return {
user,
hotPosts,
hotReplies,
timelineItems,
followers,
followings,
medals,
subscribed,
isMine,
isLoading,
tabLoading,
selectedTab,
followTab,
formatDate,
stripMarkdown,
stripMarkdownLength,
loadTimeline,
loadFollow,
loadAchievements,
loadSummary,
subscribeUser,
unsubscribeUser,
gotoTag,
hotTags,
levelInfo,
}
},
const formatDate = (d) => {
if (!d) return ''
return TimeManager.format(d)
}
const fetchUser = async () => {
const token = getToken()
const headers = token ? { Authorization: `Bearer ${token}` } : {}
const res = await fetch(`${API_BASE_URL}/api/users/${username}`, { headers })
if (res.ok) {
const data = await res.json()
user.value = data
subscribed.value = !!data.subscribed
} else if (res.status === 404) {
router.replace('/404')
}
}
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: 'fas fa-book', post: p }))
}
const repliesRes = await fetch(`${API_BASE_URL}/api/users/${username}/hot-replies`)
if (repliesRes.ok) {
const data = await repliesRes.json()
hotReplies.value = data.map((c) => ({ icon: 'fas fa-comment', comment: c }))
}
const tagsRes = await fetch(`${API_BASE_URL}/api/users/${username}/hot-tags`)
if (tagsRes.ok) {
const data = await tagsRes.json()
hotTags.value = data.map((t) => ({ icon: 'fas fa-tag', tag: t }))
}
}
const fetchTimeline = async () => {
const [postsRes, repliesRes, tagsRes] = await Promise.all([
fetch(`${API_BASE_URL}/api/users/${username}/posts?limit=50`),
fetch(`${API_BASE_URL}/api/users/${username}/replies?limit=50`),
fetch(`${API_BASE_URL}/api/users/${username}/tags?limit=50`),
])
const posts = postsRes.ok ? await postsRes.json() : []
const replies = repliesRes.ok ? await repliesRes.json() : []
const tags = tagsRes.ok ? await tagsRes.json() : []
const mapped = [
...posts.map((p) => ({
type: 'post',
icon: 'fas fa-book',
post: p,
createdAt: p.createdAt,
})),
...replies.map((r) => ({
type: r.parentComment ? 'reply' : 'comment',
icon: 'fas fa-comment',
comment: r,
createdAt: r.createdAt,
})),
...tags.map((t) => ({
type: 'tag',
icon: 'fas fa-tag',
tag: t,
createdAt: t.createdAt,
})),
]
mapped.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
timelineItems.value = mapped
}
const fetchFollowUsers = async () => {
const [followerRes, followingRes] = await Promise.all([
fetch(`${API_BASE_URL}/api/users/${username}/followers`),
fetch(`${API_BASE_URL}/api/users/${username}/following`),
])
followers.value = followerRes.ok ? await followerRes.json() : []
followings.value = followingRes.ok ? await followingRes.json() : []
}
const loadSummary = async () => {
tabLoading.value = true
await fetchSummary()
tabLoading.value = false
}
const loadTimeline = async () => {
tabLoading.value = true
await fetchTimeline()
tabLoading.value = false
}
const loadFollow = async () => {
tabLoading.value = true
await fetchFollowUsers()
tabLoading.value = false
}
const fetchAchievements = async () => {
const res = await fetch(`${API_BASE_URL}/api/medals?userId=${user.value.id}`)
if (res.ok) {
medals.value = await res.json()
} else {
medals.value = []
toast.error('获取成就失败')
}
}
const loadAchievements = async () => {
tabLoading.value = true
await fetchAchievements()
tabLoading.value = false
}
const subscribeUser = async () => {
const token = getToken()
if (!token) {
toast.error('请先登录')
return
}
const res = await fetch(`${API_BASE_URL}/api/subscriptions/users/${username}`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
})
if (res.ok) {
subscribed.value = true
toast.success('已关注')
} else {
toast.error('操作失败')
}
}
const unsubscribeUser = async () => {
const token = getToken()
if (!token) {
toast.error('请先登录')
return
}
const res = await fetch(`${API_BASE_URL}/api/subscriptions/users/${username}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` },
})
if (res.ok) {
subscribed.value = false
toast.success('已取消关注')
} else {
toast.error('操作失败')
}
}
const gotoTag = (tag) => {
const value = encodeURIComponent(tag.id ?? tag.name)
navigateTo({ path: '/', query: { tags: value } }, { replace: true })
}
const init = async () => {
try {
await fetchUser()
if (selectedTab.value === 'summary') {
await loadSummary()
} else if (selectedTab.value === 'timeline') {
await loadTimeline()
} else if (selectedTab.value === 'following') {
await loadFollow()
} else if (selectedTab.value === 'achievements') {
await loadAchievements()
}
} catch (e) {
console.error(e)
} finally {
isLoading.value = false
}
}
onMounted(init)
watch(selectedTab, async (val) => {
// router.replace({ query: { ...route.query, tab: val } })
if (val === 'timeline' && timelineItems.value.length === 0) {
await loadTimeline()
} else if (val === 'following' && followers.value.length === 0 && followings.value.length === 0) {
await loadFollow()
} else if (val === 'achievements' && medals.value.length === 0) {
await loadAchievements()
}
})
</script>
<style scoped>

View File

@@ -1,3 +1,4 @@
import { defineNuxtPlugin } from 'nuxt/app'
import ClickOutside from '~/directives/clickOutside.js'
export default defineNuxtPlugin((nuxtApp) => {

View File

@@ -1,5 +1,5 @@
// plugins/ldrs.client.ts
import { defineNuxtPlugin } from '#app'
import { defineNuxtPlugin } from 'nuxt/app'
export default defineNuxtPlugin(async () => {
// 动态引入,防止打包时把 ldrs 拉进 SSR bundle

View File

@@ -1,5 +1,6 @@
import NProgress from 'nprogress'
import 'nprogress/nprogress.css'
import { defineNuxtPlugin } from 'nuxt/app'
export default defineNuxtPlugin((nuxtApp) => {
NProgress.configure({ showSpinner: false })
@@ -12,7 +13,7 @@ export default defineNuxtPlugin((nuxtApp) => {
NProgress.done()
})
nuxtApp.hook('page:error', () => {
nuxtApp.hook('app:error', () => {
NProgress.done()
})
})

View File

@@ -1,4 +1,4 @@
import { defineNuxtPlugin } from '#app'
import { defineNuxtPlugin } from 'nuxt/app'
import { initTheme } from '~/utils/theme'
export default defineNuxtPlugin(() => {

View File

@@ -1,4 +1,4 @@
import { defineNuxtPlugin } from '#app'
import { defineNuxtPlugin } from 'nuxt/app'
import 'vue-toastification/dist/index.css'
import '~/assets/toast.css'

View File

Binary file not shown.

View File

Binary file not shown.

View File

Binary file not shown.

View File

Binary file not shown.

View File

Binary file not shown.

View File

Binary file not shown.

View File

Binary file not shown.

View File

Binary file not shown.

View File

Binary file not shown.

View File

Binary file not shown.

View File

Binary file not shown.

View File

Binary file not shown.

View File

Binary file not shown.

View File

Binary file not shown.

View File

Binary file not shown.

View File

Binary file not shown.

View File

@@ -1,7 +0,0 @@
export default {
push(path) {
if (process.client) {
window.location.href = path
}
},
}

View File

@@ -1,4 +1,3 @@
import { API_BASE_URL } from '~/main'
import { reactive } from 'vue'
const TOKEN_KEY = 'token'
@@ -65,6 +64,8 @@ export function clearUserInfo() {
}
export async function fetchCurrentUser() {
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
const token = getToken()
if (!token) return null
try {
@@ -91,6 +92,8 @@ export function isLogin() {
}
export async function checkToken() {
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
const token = getToken()
if (!token) return false
try {

View File

@@ -1,9 +1,11 @@
import { API_BASE_URL, DISCORD_CLIENT_ID, toast } from '../main'
import { WEBSITE_BASE_URL } from '../constants'
import { toast } from '../main'
import { setToken, loadCurrentUser } from './auth'
import { registerPush } from './push'
export function discordAuthorize(state = '') {
const config = useRuntimeConfig()
const WEBSITE_BASE_URL = config.public.websiteBaseUrl
const DISCORD_CLIENT_ID = config.public.discordClientId
if (!DISCORD_CLIENT_ID) {
toast.error('Discord 登录不可用')
return
@@ -15,6 +17,8 @@ export function discordAuthorize(state = '') {
export async function discordExchange(code, state, reason) {
try {
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
const res = await fetch(`${API_BASE_URL}/api/auth/discord`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },

View File

@@ -1,9 +1,11 @@
import { API_BASE_URL, GITHUB_CLIENT_ID, toast } from '../main'
import { toast } from '../main'
import { setToken, loadCurrentUser } from './auth'
import { WEBSITE_BASE_URL } from '../constants'
import { registerPush } from './push'
export function githubAuthorize(state = '') {
const config = useRuntimeConfig()
const WEBSITE_BASE_URL = config.public.websiteBaseUrl
const GITHUB_CLIENT_ID = config.public.githubClientId
if (!GITHUB_CLIENT_ID) {
toast.error('GitHub 登录不可用')
return
@@ -15,6 +17,8 @@ export function githubAuthorize(state = '') {
export async function githubExchange(code, state, reason) {
try {
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
const res = await fetch(`${API_BASE_URL}/api/auth/github`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },

View File

@@ -1,9 +1,11 @@
import { API_BASE_URL, GOOGLE_CLIENT_ID, toast } from '../main'
import { toast } from '../main'
import { setToken, loadCurrentUser } from './auth'
import { registerPush } from './push'
import { WEBSITE_BASE_URL } from '../constants'
export async function googleGetIdToken() {
const config = useRuntimeConfig()
const GOOGLE_CLIENT_ID = config.public.googleClientId
return new Promise((resolve, reject) => {
if (!window.google || !GOOGLE_CLIENT_ID) {
toast.error('Google 登录不可用, 请检查网络设置与VPN')
@@ -20,6 +22,9 @@ export async function googleGetIdToken() {
}
export function googleAuthorize() {
const config = useRuntimeConfig()
const GOOGLE_CLIENT_ID = config.public.googleClientId
const WEBSITE_BASE_URL = config.public.websiteBaseUrl
if (!GOOGLE_CLIENT_ID) {
toast.error('Google 登录不可用, 请检查网络设置与VPN')
return
@@ -32,6 +37,8 @@ export function googleAuthorize() {
export async function googleAuthWithToken(idToken, redirect_success, redirect_not_approved) {
try {
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
const res = await fetch(`${API_BASE_URL}/api/auth/google`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -65,15 +72,13 @@ export async function googleSignIn(redirect_success, redirect_not_approved) {
}
}
import router from '../router'
export function loginWithGoogle() {
googleSignIn(
() => {
router.push('/')
navigateTo('/', { replace: true })
},
(token) => {
router.push('/signup-reason?token=' + token)
navigateTo(`/signup-reason?token=${token}`, { replace: true })
},
)
}

View File

@@ -1,6 +1,15 @@
import MarkdownIt from 'markdown-it'
import hljs from 'highlight.js'
import 'highlight.js/styles/github.css'
if (typeof window !== 'undefined') {
const theme =
document.documentElement.dataset.theme ||
(window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
if (theme === 'dark') {
import('highlight.js/styles/atom-one-dark.css')
} else {
import('highlight.js/styles/atom-one-light.css')
}
}
import MarkdownIt from 'markdown-it'
import { toast } from '../main'
import { tiebaEmoji } from './tiebaEmoji'
@@ -50,6 +59,31 @@ function tiebaEmojiPlugin(md) {
})
}
// 链接在新窗口打开
function linkPlugin(md) {
const defaultRender =
md.renderer.rules.link_open ||
function (tokens, idx, options, env, self) {
return self.renderToken(tokens, idx, options)
}
md.renderer.rules.link_open = function (tokens, idx, options, env, self) {
const token = tokens[idx]
const hrefIndex = token.attrIndex('href')
if (hrefIndex >= 0) {
const href = token.attrs[hrefIndex][1]
// 如果是外部链接,添加 target="_blank" 和 rel="noopener noreferrer"
if (href.startsWith('http://') || href.startsWith('https://')) {
token.attrPush(['target', '_blank'])
token.attrPush(['rel', 'noopener noreferrer'])
}
}
return defaultRender(tokens, idx, options, env, self)
}
}
const md = new MarkdownIt({
html: false,
linkify: true,
@@ -61,12 +95,17 @@ const md = new MarkdownIt({
} else {
code = hljs.highlightAuto(str).value
}
return `<pre class="code-block"><button class="copy-code-btn">Copy</button><code class="hljs language-${lang || ''}">${code}</code></pre>`
const lineNumbers = code
.trim()
.split('\n')
.map(() => `<div class="line-number"></div>`)
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(tiebaEmojiPlugin)
md.use(linkPlugin) // 添加链接插件
export function renderMarkdown(text) {
return md.render(text || '')
@@ -86,18 +125,19 @@ export function handleMarkdownClick(e) {
export function stripMarkdown(text) {
const html = md.render(text || '')
// SSR 环境下没有 document
if (typeof window === 'undefined') {
// 用正则去除 HTML 标签
return html
.replace(/<[^>]+>/g, '')
.replace(/\s+/g, ' ')
.trim()
} else {
const el = document.createElement('div')
el.innerHTML = html
return el.textContent || el.innerText || ''
}
// 统一使用正则表达式方法,确保服务端和客户端行为一致
let plainText = html.replace(/<[^>]+>/g, '')
// 标准化空白字符处理
plainText = plainText
.replace(/\r\n/g, '\n') // Windows换行符转为Unix格式
.replace(/\r/g, '\n') // 旧Mac换行符转为Unix格式
.replace(/[ \t]+/g, ' ') // 合并空格和制表符为单个空格
.replace(/\n{3,}/g, '\n\n') // 最多保留两个连续换行(一个空行)
.trim()
return plainText
}
export function stripMarkdownLength(text, length) {

View File

@@ -1,12 +1,35 @@
import { API_BASE_URL } from '~/main'
import { getToken } from './auth'
import { reactive } from 'vue'
import { navigateTo, useRuntimeConfig } from 'nuxt/app'
import { reactive, ref } from 'vue'
import { toast } from '~/composables/useToast'
import { authState, getToken } from '~/utils/auth'
import { reactionEmojiMap } from '~/utils/reactions'
export const notificationState = reactive({
unreadCount: 0,
})
const iconMap = {
POST_VIEWED: 'fas fa-eye',
COMMENT_REPLY: 'fas fa-reply',
POST_REVIEWED: 'fas fa-shield-alt',
POST_REVIEW_REQUEST: 'fas fa-gavel',
POST_UPDATED: 'fas fa-comment-dots',
USER_ACTIVITY: 'fas fa-user',
FOLLOWED_POST: 'fas fa-feather-alt',
USER_FOLLOWED: 'fas fa-user-plus',
USER_UNFOLLOWED: 'fas fa-user-minus',
POST_SUBSCRIBED: 'fas fa-bookmark',
POST_UNSUBSCRIBED: 'fas fa-bookmark',
REGISTER_REQUEST: 'fas fa-user-clock',
ACTIVITY_REDEEM: 'fas fa-coffee',
LOTTERY_WIN: 'fas fa-trophy',
LOTTERY_DRAW: 'fas fa-bullhorn',
MENTION: 'fas fa-at',
}
export async function fetchUnreadCount() {
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
try {
const token = getToken()
if (!token) {
@@ -31,6 +54,9 @@ export async function fetchUnreadCount() {
export async function markNotificationsRead(ids) {
try {
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
const token = getToken()
if (!token || !ids || ids.length === 0) return false
const res = await fetch(`${API_BASE_URL}/api/notifications/read`, {
@@ -49,6 +75,9 @@ export async function markNotificationsRead(ids) {
export async function fetchNotificationPreferences() {
try {
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
const token = getToken()
if (!token) return []
const res = await fetch(`${API_BASE_URL}/api/notifications/prefs`, {
@@ -63,6 +92,8 @@ export async function fetchNotificationPreferences() {
export async function updateNotificationPreference(type, enabled) {
try {
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
const token = getToken()
if (!token) return false
const res = await fetch(`${API_BASE_URL}/api/notifications/prefs`, {
@@ -78,3 +109,235 @@ export async function updateNotificationPreference(type, enabled) {
return false
}
}
/**
* 处理信息的高阶函数
* @returns
*/
function createFetchNotifications() {
const notifications = ref([])
const isLoadingMessage = ref(false)
const fetchNotifications = async () => {
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
if (isLoadingMessage && notifications && markRead) {
try {
const token = getToken()
if (!token) {
toast.error('请先登录')
return
}
isLoadingMessage.value = true
notifications.value = []
const res = await fetch(`${API_BASE_URL}/api/notifications`, {
headers: {
Authorization: `Bearer ${token}`,
},
})
isLoadingMessage.value = false
if (!res.ok) {
toast.error('获取通知失败')
return
}
const data = await res.json()
for (const n of data) {
if (n.type === 'COMMENT_REPLY') {
notifications.value.push({
...n,
src: n.comment.author.avatar,
iconClick: () => {
markRead(n.id)
navigateTo(`/users/${n.comment.author.id}`, { replace: true })
},
})
} else if (n.type === 'REACTION') {
notifications.value.push({
...n,
emoji: reactionEmojiMap[n.reactionType],
iconClick: () => {
if (n.fromUser) {
markRead(n.id)
navigateTo(`/users/${n.fromUser.id}`, { replace: true })
}
},
})
} else if (n.type === 'POST_VIEWED') {
notifications.value.push({
...n,
src: n.fromUser ? n.fromUser.avatar : null,
icon: n.fromUser ? undefined : iconMap[n.type],
iconClick: () => {
if (n.fromUser) {
markRead(n.id)
navigateTo(`/users/${n.fromUser.id}`, { replace: true })
}
},
})
} else if (n.type === 'LOTTERY_WIN') {
notifications.value.push({
...n,
icon: iconMap[n.type],
iconClick: () => {
if (n.post) {
markRead(n.id)
router.push(`/posts/${n.post.id}`)
}
},
})
} else if (n.type === 'LOTTERY_DRAW') {
notifications.value.push({
...n,
icon: iconMap[n.type],
iconClick: () => {
if (n.post) {
markRead(n.id)
router.push(`/posts/${n.post.id}`)
}
},
})
} else if (n.type === 'POST_UPDATED') {
notifications.value.push({
...n,
src: n.comment.author.avatar,
iconClick: () => {
markRead(n.id)
navigateTo(`/users/${n.comment.author.id}`, { replace: true })
},
})
} else if (n.type === 'USER_ACTIVITY') {
notifications.value.push({
...n,
src: n.comment.author.avatar,
iconClick: () => {
markRead(n.id)
navigateTo(`/users/${n.comment.author.id}`, { replace: true })
},
})
} else if (n.type === 'MENTION') {
notifications.value.push({
...n,
icon: iconMap[n.type],
iconClick: () => {
if (n.fromUser) {
markRead(n.id)
navigateTo(`/users/${n.fromUser.id}`, { replace: true })
}
},
})
} else if (n.type === 'USER_FOLLOWED' || n.type === 'USER_UNFOLLOWED') {
notifications.value.push({
...n,
icon: iconMap[n.type],
iconClick: () => {
if (n.fromUser) {
markRead(n.id)
navigateTo(`/users/${n.fromUser.id}`, { replace: true })
}
},
})
} else if (n.type === 'FOLLOWED_POST') {
notifications.value.push({
...n,
icon: iconMap[n.type],
iconClick: () => {
if (n.post) {
markRead(n.id)
navigateTo(`/posts/${n.post.id}`, { replace: true })
}
},
})
} else if (n.type === 'POST_SUBSCRIBED' || n.type === 'POST_UNSUBSCRIBED') {
notifications.value.push({
...n,
icon: iconMap[n.type],
iconClick: () => {
if (n.post) {
markRead(n.id)
navigateTo(`/posts/${n.post.id}`, { replace: true })
}
},
})
} else if (n.type === 'POST_REVIEW_REQUEST') {
notifications.value.push({
...n,
src: n.fromUser ? n.fromUser.avatar : null,
icon: n.fromUser ? undefined : iconMap[n.type],
iconClick: () => {
if (n.post) {
markRead(n.id)
navigateTo(`/posts/${n.post.id}`, { replace: true })
}
},
})
} else if (n.type === 'REGISTER_REQUEST') {
notifications.value.push({
...n,
icon: iconMap[n.type],
iconClick: () => {},
})
} else {
notifications.value.push({
...n,
icon: iconMap[n.type],
})
}
}
} catch (e) {
console.error(e)
}
}
}
const markRead = async (id) => {
if (!id) return
const n = notifications.value.find((n) => n.id === id)
if (!n || n.read) return
n.read = true
if (notificationState.unreadCount > 0) notificationState.unreadCount--
const ok = await markNotificationsRead([id])
if (!ok) {
n.read = false
notificationState.unreadCount++
} else {
fetchUnreadCount()
}
}
const markAllRead = async () => {
// 除了 REGISTER_REQUEST 类型消息
const idsToMark = notifications.value
.filter((n) => n.type !== 'REGISTER_REQUEST' && !n.read)
.map((n) => n.id)
if (idsToMark.length === 0) return
notifications.value.forEach((n) => {
if (n.type !== 'REGISTER_REQUEST') n.read = true
})
notificationState.unreadCount = notifications.value.filter((n) => !n.read).length
const ok = await markNotificationsRead(idsToMark)
if (!ok) {
notifications.value.forEach((n) => {
if (idsToMark.includes(n.id)) n.read = false
})
await fetchUnreadCount()
return
}
fetchUnreadCount()
if (authState.role === 'ADMIN') {
toast.success('已读所有消息(注册请求除外)')
} else {
toast.success('已读所有消息')
}
}
return {
fetchNotifications,
markRead,
notifications,
isLoadingMessage,
markRead,
markAllRead,
}
}
export const { fetchNotifications, markRead, notifications, isLoadingMessage, markAllRead } =
createFetchNotifications()

View File

@@ -1,4 +1,3 @@
import { API_BASE_URL } from '../main'
import { getToken } from './auth'
function urlBase64ToUint8Array(base64String) {
@@ -21,6 +20,8 @@ function arrayBufferToBase64(buffer) {
export async function registerPush() {
if (!('serviceWorker' in navigator) || !('PushManager' in window)) return
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
try {
const reg = await navigator.serviceWorker.register('/notifications-sw.js')
const res = await fetch(`${API_BASE_URL}/api/push/public-key`)

View File

@@ -1,5 +1,4 @@
import { API_BASE_URL, TWITTER_CLIENT_ID, toast } from '../main'
import { WEBSITE_BASE_URL } from '../constants'
import { toast } from '../main'
import { setToken, loadCurrentUser } from './auth'
import { registerPush } from './push'
@@ -22,6 +21,9 @@ async function generateCodeChallenge(codeVerifier) {
}
export async function twitterAuthorize(state = '') {
const config = useRuntimeConfig()
const WEBSITE_BASE_URL = config.public.websiteBaseUrl
const TWITTER_CLIENT_ID = config.public.twitterClientId
if (!TWITTER_CLIENT_ID) {
toast.error('Twitter 登录不可用')
return
@@ -42,6 +44,8 @@ export async function twitterAuthorize(state = '') {
export async function twitterExchange(code, state, reason) {
try {
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
const codeVerifier = sessionStorage.getItem('twitter_code_verifier')
sessionStorage.removeItem('twitter_code_verifier')
const res = await fetch(`${API_BASE_URL}/api/auth/twitter`, {

View File

@@ -1,7 +1,7 @@
import { API_BASE_URL } from '../main'
export async function fetchFollowings(username) {
if (!username) return []
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
try {
const res = await fetch(`${API_BASE_URL}/api/users/${username}/following`)
return res.ok ? await res.json() : []
@@ -11,6 +11,8 @@ export async function fetchFollowings(username) {
}
export async function fetchAdmins() {
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
try {
const res = await fetch(`${API_BASE_URL}/api/users/admins`)
return res.ok ? await res.json() : []
@@ -21,6 +23,8 @@ export async function fetchAdmins() {
export async function searchUsers(keyword) {
if (!keyword) return []
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
try {
const res = await fetch(
`${API_BASE_URL}/api/search/users?keyword=${encodeURIComponent(keyword)}`,

View File

@@ -1,5 +1,4 @@
import Vditor from 'vditor'
import { API_BASE_URL } from '../main'
import { getToken, authState } from './auth'
import { searchUsers, fetchFollowings, fetchAdmins } from './user'
import { tiebaEmoji } from './tiebaEmoji'
@@ -14,6 +13,8 @@ export function getPreviewTheme() {
export function createVditor(editorId, options = {}) {
const { placeholder = '', preview = {}, input, after } = options
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
const fetchMentions = async (value) => {
if (!value) {