Compare commits

...

111 Commits

Author SHA1 Message Date
Tim
2322b2da15 feat: remember selected tab 2025-08-16 16:10:37 +08:00
tim
79261054f9 feat: ci & cd 2025-08-16 15:24:32 +08:00
tim
86633e1f21 feat: ci & cd 2025-08-16 15:23:54 +08:00
tim
784598a6f0 feat: ci & cd 2025-08-16 15:23:05 +08:00
tim
fdad0e5d34 feat: cd & cd 2025-08-16 15:21:53 +08:00
tim
ebf63c4072 feat: test commit 2025-08-16 15:20:46 +08:00
tim
354d6bdaf9 Merge branch 'main' of github.com:nagisa77/OpenIsle 2025-08-16 15:19:21 +08:00
tim
d9aebdebdc feat: 预发环境 2025-08-16 15:19:10 +08:00
Tim
d6f6495b35 Merge pull request #595 from AnNingUI/main
fix: 修复我的信息界面中的header无法粘性布局的bug以及解决了一些-webkit样式警告
2025-08-16 15:02:48 +08:00
AnNingUI
300f8705ef fix: 修复我的信息界面中的header无法粘性布局的bug以及解决了一些-webkit样式警告
fixed: #588
2025-08-16 13:49:24 +08:00
tim
1f74a29dce fix: 修复header 显示异常 2025-08-16 11:34:03 +08:00
Tim
27ef792b11 Merge pull request #594 from immortal521/feat/user-menu-animation
feat: add transition effects for page and dropdown
2025-08-16 11:25:45 +08:00
Tim
8dd2d59617 Merge pull request #593 from immortal521/fix/mobile-theme-toggle-position
fix: incorrect animation start position on mobile theme toggle
2025-08-16 11:25:06 +08:00
Tim
077ba448d7 Merge pull request #592 from immortal521/fix/unlogin-cant-change-theme
fix: allow theme toggle without requiring user login
2025-08-16 11:22:36 +08:00
immortal521
9ce85f2769 fix: fix incorrect animation start position on mobile theme toggle
- Unified coordinate handling for mouse and touch events to ensure the
animation start point accurately follows the finger position on mobile
devices.
2025-08-16 01:45:57 +08:00
immortal521
f5557cbf08 feat: add transition effects for page and dropdown
- Add page transition CSS with opacity and blur effects

- Wrap dropdown in Transition component with slide effect

- Configure Nuxt pageTransition in config
2025-08-16 01:22:56 +08:00
immortal521
e042c499e1 fix: allow theme toggle without requiring user login 2025-08-16 01:11:30 +08:00
tim
e01afb168c Merge branch 'main' of github.com:nagisa77/OpenIsle 2025-08-16 01:11:14 +08:00
tim
c1d81eb1d1 fix: update staging base url 2025-08-16 01:11:02 +08:00
Tim
2b0b429866 Merge pull request #589 from AnNingUI/main
fix: 添加对非startViewTransition支持的浏览器添加一个回退的主题切换动画
2025-08-16 00:34:08 +08:00
AnNingUI
8ea85d78ee fix: 解决menu-background-color变量被firefox的userChrome.css覆盖问题 2025-08-15 22:54:49 +08:00
AnNingUI
3b506fe8a8 Merge branch 'main' of github.com:AnNingUI/OpenIsle 2025-08-15 22:25:40 +08:00
AnNingUI
3cc7a4c01a fix: 添加对非startViewTransition支持的浏览器添加一个回退的主题切换动画
fixes: #583
2025-08-15 22:25:02 +08:00
tim
2e749a5672 fix: update 后端端口 2025-08-15 18:16:10 +08:00
tim
7d553d7750 fix: add staging example file 2025-08-15 17:50:20 +08:00
Tim
16105cef54 Merge pull request #584 from CH-122/fix/mobile-theme-mode
fix: 手机状态栏暗黑模式背景颜色显示不正确
2025-08-15 15:48:09 +08:00
CH-122
2b824d94f2 fix: 更新新增帖子图标类名并调整样式作用域 2025-08-15 15:47:24 +08:00
CH-122
00d3c563e2 feat: 移动端 header 中添加主题切换图标, 菜单中隐藏 2025-08-15 15:36:25 +08:00
CH-122
b26891261c fix: 适配 ios safari 浏览器暗黑模式 2025-08-15 15:23:49 +08:00
CH-122
c1d19b854b fix: 手机状态栏暗黑模式背景颜色显示不正确 2025-08-15 14:52:30 +08:00
Tim
72e7ccf262 Merge pull request #581 from immortal521/feat/theme-toggle-transition
feat: implement theme transition animations and dark mode improvements
2025-08-15 13:27:44 +08:00
tim
84ca6fd28c feat: add refresh home 2025-08-15 13:24:00 +08:00
immortal521
d1c148c5c4 Merge branch 'main' into feat/theme-toggle-transition 2025-08-15 13:20:37 +08:00
immortal521
ef58630dae feat: implement theme transition animations and dark mode improvements
- Add view transition API for theme switching

- Update cycleTheme to handle animation circle

- Refactor CSS with consistent quoting and indentation

- Improve theme variable handling and no-op optimizations

- Pass event to cycleTheme in MenuComponent
2025-08-15 13:12:27 +08:00
Tim
f025e82e7c Merge pull request #580 from nagisa77/codex/resolve-chunk-size-warning-issue
chore: split large vite chunks
2025-08-15 13:11:00 +08:00
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
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
49 changed files with 5131 additions and 2534 deletions

23
.github/workflows/deploy-staging.yml vendored Normal file
View File

@@ -0,0 +1,23 @@
name: Staging CI & CD
on:
push:
branches: [main]
workflow_dispatch:
jobs:
build-and-deploy:
runs-on: ubuntu-latest
environment: Deploy
steps:
- uses: actions/checkout@v4
- name: Deploy to Server
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.SSH_HOST }}
username: root
key: ${{ secrets.SSH_KEY }}
script: bash /opt/openisle/deploy-staging.sh

View File

@@ -1,9 +1,9 @@
name: CI & CD
on:
push:
branches: [main]
workflow_dispatch:
schedule:
- cron: "0 19 * * *" # 每天 UTC 19:00相当于北京时间凌晨3点
jobs:
build-and-deploy:
@@ -13,22 +13,6 @@ jobs:
steps:
- uses: actions/checkout@v4
# - uses: actions/setup-java@v4
# with:
# java-version: '17'
# distribution: 'temurin'
# - run: mvn -B clean package -DskipTests
# - uses: actions/setup-node@v4
# with:
# node-version: '20'
# - run: |
# cd open-isle-cli
# npm ci
# npm run build
- name: Deploy to Server
uses: appleboy/ssh-action@v1.0.3
with:

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

@@ -75,14 +75,16 @@ public class SecurityConfig {
cfg.setAllowedOrigins(List.of(
"http://127.0.0.1:8080",
"http://127.0.0.1:3000",
"http://127.0.0.1:3001",
"http://127.0.0.1",
"http://localhost:8080",
"http://localhost:3000",
"http://localhost:3001",
"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

@@ -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

@@ -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

@@ -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

@@ -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;
@@ -567,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

@@ -1,6 +1,15 @@
; 本地部署后端
; NUXT_PUBLIC_API_BASE_URL=https://127.0.0.1:8081
; 预发环境后端
; NUXT_PUBLIC_API_BASE_URL=https://staging.open-isle.com
; 生产环境后端
NUXT_PUBLIC_API_BASE_URL=https://www.open-isle.com
; 预发环境
; NUXT_PUBLIC_WEBSITE_BASE_URL=https://staging.open-isle.com
; 正式环境/生产环境
NUXT_PUBLIC_WEBSITE_BASE_URL=https://www.open-isle.com
NUXT_PUBLIC_GOOGLE_CLIENT_ID=777830451304-xxx.apps.googleusercontent.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

@@ -0,0 +1,16 @@
; 本地部署后端
; NUXT_PUBLIC_API_BASE_URL=https://127.0.0.1:8081
; 预发环境后端
NUXT_PUBLIC_API_BASE_URL=https://staging.open-isle.com
; 生产环境后端
; NUXT_PUBLIC_API_BASE_URL=https://www.open-isle.com
; 预发环境
NUXT_PUBLIC_WEBSITE_BASE_URL=https://staging.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

@@ -15,62 +15,77 @@
<div class="content" :class="{ 'menu-open': menuVisible && !hideMenu }">
<NuxtPage keepalive />
</div>
<div v-if="showNewPostIcon && isMobile" class="app-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)
const header = useTemplateRef('header')
const showNewPostIcon = computed(() => useRoute().path === '/')
onMounted(() => {
if (typeof window !== 'undefined') {
menuVisible.value = window.innerWidth > 768
}
})
const hideMenu = computed(() => {
return [
'/login',
'/signup',
'/404',
'/signup-reason',
'/github-callback',
'/twitter-callback',
'/discord-callback',
'/forgot-password',
'/google-callback',
].includes(useRoute().path)
})
const handleMenuOutside = (event) => {
const btn = header.value.$refs.menuBtn
if (btn && (btn === event.target || btn.contains(event.target))) {
return // 如果是菜单按钮的点击,不处理关闭
}
const header = useTemplateRef('header')
if (isMobile.value) {
menuVisible.value = false
}
}
onMounted(() => {
if (typeof window !== 'undefined') {
menuVisible.value = window.innerWidth > 768
}
})
return { menuVisible, hideMenu, handleMenuOutside, header }
},
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>
/* 页面过渡效果 */
.page-enter-active,
.page-leave-active {
transition: all 0.4s;
}
.page-enter-from,
.page-leave-to {
opacity: 0;
filter: blur(10px);
}
.header-container {
position: fixed;
top: 0;
@@ -103,6 +118,24 @@ export default {
margin: 0 auto;
}
.app-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,11 +2,13 @@
--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;
--header-text-color: black;
--menu-background-color: white;
/* 加一个app前缀防止与firefox的userChrome.css中的--menu-background-color冲突 */
--app-menu-background-color: white;
--background-color: white;
/* --background-color-blur: rgba(255, 255, 255, 0.57); */
--background-color-blur: var(--background-color);
@@ -15,14 +17,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;
@@ -33,8 +37,9 @@
--header-border-color: #555;
--primary-color: rgb(17, 182, 197);
--primary-color-hover: rgb(13, 137, 151);
--new-post-icon-color: rgba(10, 111, 120, 0.598);
--header-text-color: white;
--menu-background-color: #333;
--app-menu-background-color: #333;
--background-color: #333;
/* --background-color-blur: #333333a4; */
--background-color-blur: var(--background-color);
@@ -42,8 +47,10 @@
--normal-border-color: #555;
--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 +138,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 +193,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 +297,7 @@ body {
}
.info-content-text pre {
line-height: 1.1;
line-height: 1.5;
}
.vditor-panel {
@@ -276,6 +306,29 @@ body {
}
}
/* Transition API */
::view-transition-old(root),
::view-transition-new(root) {
animation: none;
mix-blend-mode: normal;
}
::view-transition-old(root) {
z-index: 1;
}
::view-transition-new(root) {
z-index: 2147483646;
}
[data-theme='dark']::view-transition-old(root) {
z-index: 2147483646;
}
[data-theme='dark']::view-transition-new(root) {
z-index: 1;
}
/* NProgress styles */
#nprogress {
pointer-events: none;

View File

@@ -114,7 +114,7 @@
</template>
<script>
import { ref, computed, watch, onMounted } from 'vue'
import { computed, onMounted, ref, watch } from 'vue'
import { useIsMobile } from '~/utils/screen'
export default {
@@ -312,7 +312,7 @@ export default {
border: none;
outline: none;
margin-left: 5px;
background-color: var(--menu-background-color);
background-color: var(--app-menu-background-color);
color: var(--text-color);
}
@@ -352,7 +352,7 @@ export default {
left: 0;
right: 0;
bottom: 0;
background-color: var(--menu-background-color);
background-color: var(--app-menu-background-color);
z-index: 1300;
display: flex;
flex-direction: column;

View File

@@ -3,22 +3,24 @@
<div class="dropdown-trigger" @click="toggle">
<slot name="trigger"></slot>
</div>
<div v-if="visible" class="dropdown-menu-container">
<div
v-for="(item, idx) in items"
:key="idx"
class="dropdown-item"
:style="{ color: item.color || 'inherit' }"
@click="handle(item)"
>
{{ item.text }}
<Transition name="dropdown-menu">
<div v-if="visible" class="dropdown-menu-container">
<div
v-for="(item, idx) in items"
:key="idx"
class="dropdown-item"
:style="{ color: item.color || 'inherit' }"
@click="handle(item)"
>
{{ item.text }}
</div>
</div>
</div>
</Transition>
</div>
</template>
<script>
import { ref, onMounted, onBeforeUnmount } from 'vue'
import { onBeforeUnmount, onMounted, ref } from 'vue'
export default {
name: 'DropdownMenu',
props: {
@@ -61,17 +63,28 @@ export default {
position: relative;
display: inline-block;
}
.dropdown-trigger {
cursor: pointer;
display: inline-flex;
align-items: center;
}
.dropdown-menu-enter-active,
.dropdown-menu-leave-active {
transition: all 0.4s;
}
.dropdown-menu-enter-from,
.dropdown-menu-leave-to {
opacity: 0;
transform: translateY(-16px);
}
.dropdown-menu-container {
position: absolute;
top: 100%;
right: 0;
background-color: var(--menu-background-color);
background-color: var(--app-menu-background-color);
border: 1px solid var(--normal-border-color);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
border-radius: 8px;
@@ -82,7 +95,9 @@ export default {
.dropdown-item {
padding: 8px 16px;
white-space: nowrap;
cursor: pointer;
}
.dropdown-item:hover {
background-color: var(--menu-selected-background-color);
}

View File

@@ -8,7 +8,7 @@
</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,15 +16,26 @@
height="60"
/>
<div class="logo-text">OpenIsle</div>
</div>
</NuxtLink>
</div>
<ClientOnly>
<div v-if="isLogin" class="header-content-right">
<div class="header-content-right">
<div v-if="isMobile" class="search-icon" @click="search">
<i class="fas fa-search"></i>
</div>
<DropdownMenu ref="userMenu" :items="headerMenuItems">
<div v-if="isMobile" class="theme-icon" @click="cycleTheme">
<i :class="iconClass"></i>
</div>
<ToolTip v-if="!isMobile && isLogin" content="发帖" placement="bottom">
<div class="new-post-icon" @click="goToNewPost">
<i class="fas fa-edit"></i>
</div>
</ToolTip>
<DropdownMenu v-if="isLogin" ref="userMenu" :items="headerMenuItems">
<template #trigger>
<div class="avatar-container">
<img class="avatar-img" :src="avatar" alt="avatar" />
@@ -32,14 +43,11 @@
</div>
</template>
</DropdownMenu>
</div>
<div v-else class="header-content-right">
<div v-if="isMobile" class="search-icon" @click="search">
<i class="fas fa-search"></i>
<div v-if="!isLogin" class="auth-btns">
<div class="header-content-item-main" @click="goToLogin">登录</div>
<div class="header-content-item-secondary" @click="goToSignup">注册</div>
</div>
<div class="header-content-item-main" @click="goToLogin">登录</div>
<div class="header-content-item-secondary" @click="goToSignup">注册</div>
</div>
</ClientOnly>
@@ -51,12 +59,14 @@
<script setup>
import { ClientOnly } from '#components'
import { computed, nextTick, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
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'
import { themeState, cycleTheme, ThemeMode } from '~/utils/theme'
const props = defineProps({
showMenuBtn: {
type: Boolean,
@@ -67,20 +77,12 @@ const props = defineProps({
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 menuBtn = ref(null)
const goToHome = async () => {
if (router.currentRoute.value.fullPath === '/') {
window.dispatchEvent(new Event('refresh-home'))
} else {
await navigateTo('/', { replace: true })
}
}
const search = () => {
showSearch.value = true
nextTick(() => {
@@ -122,12 +124,33 @@ const goToLogout = () => {
navigateTo('/login', { replace: true })
}
const goToNewPost = () => {
navigateTo('/new-post', { replace: false })
}
const refrechData = async () => {
await fetchUnreadCount()
window.dispatchEvent(new Event('refresh-home'))
}
const headerMenuItems = computed(() => [
{ text: '设置', onClick: goToSettings },
{ text: '个人主页', onClick: goToProfile },
{ text: '退出', onClick: goToLogout },
])
/** 其余逻辑保持不变 */
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'
}
})
onMounted(async () => {
const updateAvatar = async () => {
if (authState.loggedIn) {
@@ -155,22 +178,14 @@ onMounted(async () => {
await updateUnread()
},
)
watch(
() => router.currentRoute.value.fullPath,
() => {
if (userMenu.value) userMenu.value.close()
showSearch.value = false
},
)
})
</script>
<style scoped>
.header {
display: flex;
justify-content: space-between;
align-items: center;
justify-content: center;
height: var(--header-height);
background-color: var(--background-color-blur);
backdrop-filter: blur(10px);
@@ -184,16 +199,16 @@ onMounted(async () => {
font-size: 20px;
font-weight: bold;
cursor: pointer;
text-decoration: none;
color: inherit;
}
.header-content {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
width: 100%;
height: 100%;
margin: 0 auto;
max-width: var(--page-max-width);
}
@@ -204,6 +219,14 @@ onMounted(async () => {
}
.header-content-right {
display: flex;
margin-left: auto;
flex-direction: row;
align-items: center;
gap: 20px;
}
.auth-btns {
display: flex;
flex-direction: row;
align-items: center;
@@ -285,11 +308,18 @@ onMounted(async () => {
background-color: var(--menu-selected-background-color);
}
.search-icon {
.search-icon,
.theme-icon {
font-size: 18px;
cursor: pointer;
}
.new-post-icon {
font-size: 18px;
cursor: pointer;
margin-right: 10px;
}
@media (max-width: 1200px) {
.header-content {
padding-left: 15px;

View File

@@ -1,168 +1,181 @@
<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 v-if="!isMobile">
<div class="menu-footer">
<div class="menu-footer-btn" @click="cycleTheme">
<i :class="iconClass"></i>
</div>
</div>
</div>
</ClientOnly>
</nav>
</transition>
</template>
<script setup>
import { ref, computed, watch, onMounted } from 'vue'
import { themeState, cycleTheme, ThemeMode } from '~/utils/theme'
import { computed, onMounted, ref, watch } from 'vue'
import { authState } from '~/utils/auth'
import { fetchUnreadCount, notificationState } from '~/utils/notification'
import { useIsMobile } from '~/utils/screen'
import { cycleTheme, ThemeMode, themeState } from '~/utils/theme'
const isMobile = useIsMobile()
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
const props = defineProps({
visible: {
type: Boolean,
default: true,
},
visible: { type: Boolean, default: true },
})
const emit = defineEmits(['item-click'])
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
}
/** ✅ 用 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,
},
)
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 {
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 iconClass = computed(() => {
switch (themeState.mode) {
case ThemeMode.DARK:
@@ -188,13 +201,10 @@ const updateCount = async () => {
onMounted(async () => {
await updateCount()
// 登录态变化时再拉一次未读数;与 useAsyncData 无关
watch(() => authState.loggedIn, updateCount)
})
const handleHomeClick = () => {
navigateTo('/', { replace: true })
}
const handleItemClick = () => {
if (window.innerWidth <= 768) emit('item-click')
}
@@ -215,28 +225,34 @@ const gotoTag = (t) => {
navigateTo({ path: '/', query: { tags: value } }, { replace: true })
handleItemClick()
}
await Promise.all([fetchCategoryData(), fetchTagData()])
</script>
<style scoped>
.menu {
position: sticky;
top: var(--header-height);
width: 200px;
background-color: var(--menu-background-color);
width: 220px;
background-color: var(--app-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;
@@ -282,10 +298,8 @@ await Promise.all([fetchCategoryData(), fetchTagData()])
}
.menu-footer {
position: fixed;
position: relation;
height: 30px;
bottom: 10px;
right: 10px;
display: flex;
align-items: center;
justify-content: flex-end;
@@ -373,6 +387,10 @@ await Promise.all([fetchCategoryData(), fetchTagData()])
background-color: var(--background-color-blur);
}
.menu-content {
border-radius: 20px;
}
.slide-enter-active,
.slide-leave-active {
transition:

View File

@@ -37,10 +37,10 @@
</template>
<script setup>
import { useIsMobile } from '~/utils/screen'
import { ref, watch } from 'vue'
import Dropdown from '~/components/Dropdown.vue'
import { stripMarkdown } from '~/utils/markdown'
import { ref, watch } from 'vue'
import { useIsMobile } from '~/utils/screen'
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
@@ -110,6 +110,10 @@ watch(selected, (val) => {
selected.value = null
keyword.value = ''
})
defineExpose({
toggle,
})
</script>
<style scoped>
@@ -131,7 +135,7 @@ watch(selected, (val) => {
}
.text-input {
background-color: var(--menu-background-color);
background-color: var(--app-menu-background-color);
color: var(--text-color);
border: none;
outline: none;

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

@@ -1 +1,5 @@
export { toast } from './composables/useToast'
export const API_DOMAIN = 'https://www.open-isle.com'
export const API_PORT = ''
export const API_BASE_URL = API_PORT ? `${API_DOMAIN}:${API_PORT}` : API_DOMAIN

View File

@@ -12,9 +12,10 @@ export default defineNuxtConfig({
twitterClientId: process.env.NUXT_PUBLIC_TWITTER_CLIENT_ID || '',
},
},
// Ensure Vditor styles load before our overrides in global.css
css: ['vditor/dist/index.css', '~/assets/global.css'],
// 确保 Vditor 样式在 global.css 覆盖前加载
css: ['vditor/dist/index.css', '~/assets/fonts.css', '~/assets/global.css'],
app: {
pageTransition: { name: 'page', mode: 'out-in' },
head: {
script: [
{
@@ -26,7 +27,31 @@ export default defineNuxtConfig({
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const theme = mode === 'dark' || mode === 'light' ? mode : (prefersDark ? 'dark' : 'light');
document.documentElement.dataset.theme = theme;
} catch (e) {}
let themeColor = '#fff';
let themeStatus = 'default';
if (theme === 'dark') {
themeColor = '#333';
themeStatus = 'black-translucent';
} else {
themeColor = '#ffffff';
themeStatus = 'default';
}
const androidMeta = document.createElement('meta');
androidMeta.name = 'theme-color';
androidMeta.content = themeColor;
const iosMeta = document.createElement('meta');
iosMeta.name = 'apple-mobile-web-app-status-bar-style';
iosMeta.content = themeStatus;
document.head.appendChild(androidMeta);
document.head.appendChild(iosMeta);
} catch (e) {
console.warn('Theme initialization failed:', e);
}
})();
`,
},
@@ -52,10 +77,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

@@ -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,6 +20,10 @@
<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>
@@ -26,6 +31,8 @@
<script setup>
import { toast } from '~/main'
import BaseInput from '~/components/BaseInput.vue'
import { useRoute } from 'vue-router'
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
@@ -39,6 +46,7 @@ const passwordError = ref('')
const isSending = ref(false)
const isVerifying = ref(false)
const isResetting = ref(false)
const route = useRoute()
onMounted(() => {
if (route.query.email) {
@@ -137,6 +145,21 @@ const resetPassword = async () => {
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

@@ -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,15 +106,14 @@
热门帖子功能开发中,敬请期待。
</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 setup>
import { ref, watch, onMounted, onBeforeUnmount } from 'vue'
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'
@@ -142,22 +143,31 @@ const selectedTags = ref([])
const route = useRoute()
const tagOptions = ref([])
const categoryOptions = ref([])
const isLoadingPosts = ref(false)
const isLoadingMore = ref(false)
const topics = ref(['最新回复', '最新', '排行榜' /*, '热门', '类别'*/])
const selectedTopic = ref(
route.query.view === 'ranking' ? '排行榜' : route.query.view === 'latest' ? '最新' : '最新回复',
)
if (import.meta.client) {
const storedTopic = localStorage.getItem('home-selected-topic')
if (storedTopic && topics.value.includes(storedTopic)) {
selectedTopic.value = storedTopic
}
}
const articles = ref([])
const page = ref(0)
const pageSize = 10
const isMobile = useIsMobile()
const allLoaded = ref(false)
/** 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
@@ -167,23 +177,17 @@ const selectedTagsSet = (tags) => {
.map((v) => (isNaN(v) ? v : Number(v)))
}
/** 初始化:仅在客户端首渲染时根据路由同步一次 **/
onMounted(() => {
const query = route.query
const category = query.category
const tags = query.tags
if (category) {
selectedCategorySet(category)
}
if (tags) {
selectedTagsSet(tags)
}
const { category, tags } = route.query
if (category) selectedCategorySet(category)
if (tags) selectedTagsSet(tags)
})
/** 路由变更时同步筛选 **/
watch(
() => route.query,
() => {
const query = route.query
(query) => {
const category = query.category
const tags = query.tags
category && selectedCategorySet(category)
@@ -191,18 +195,14 @@ watch(
},
)
/** 选项加载(分类/标签名称回填) **/
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 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) {
@@ -210,218 +210,163 @@ const loadOptions = async () => {
try {
const r = await fetch(`${API_BASE_URL}/api/tags/${t}`)
if (r.ok) arr.push(await r.json())
} catch (e) {
/* ignore */
}
} catch {}
}
}
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
/** 列表 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 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)
}
}
const refreshHome = () => {
fetchContent(true)
}
onMounted(() => {
window.addEventListener('refresh-home', refreshHome)
const tokenHeader = computed(() => {
const token = getToken()
return token ? { Authorization: `Bearer ${token}` } : {}
})
onBeforeUnmount(() => {
window.removeEventListener('refresh-home', refreshHome)
})
/** —— 首屏数据托管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],
},
)
useScrollLoadMore(fetchContent)
/** 首屏/筛选变更:重置分页并灌入 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], () => {
fetchContent(true)
loadOptions()
})
watch(selectedTopic, (topic) => {
if (import.meta.client) {
localStorage.setItem('home-selected-topic', topic)
}
// 仅当需要额外选项时加载
loadOptions()
})
watch(selectedTopic, () => {
fetchContent(true)
/** 选项首屏加载:服务端执行一次;客户端兜底 **/
if (import.meta.server) {
await loadOptions()
}
onMounted(() => {
if (categoryOptions.value.length === 0 && tagOptions.value.length === 0) loadOptions()
window.addEventListener('refresh-home', refreshFirst)
})
/** 其他工具函数 **/
const sanitizeDescription = (text) => stripMarkdown(text)
await Promise.all([loadOptions(), fetchContent()])
</script>
<style scoped>
@@ -435,8 +380,8 @@ await Promise.all([loadOptions(), fetchContent()])
}
.search-container {
margin-top: 100px;
padding: 20px;
margin-top: 32px;
padding: 20px 20px 32px;
display: flex;
flex-direction: column;
align-items: center;
@@ -448,10 +393,6 @@ await Promise.all([loadOptions(), fetchContent()])
font-weight: bold;
}
.search-subtitle {
font-size: 16px;
}
.loading-container {
display: flex;
justify-content: center;
@@ -605,6 +546,7 @@ await Promise.all([loadOptions(), fetchContent()])
font-size: 14px;
color: gray;
display: -webkit-box;
line-clamp: 3;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;

View File

@@ -505,21 +505,26 @@
</template>
<script setup>
import { ref, onMounted, computed } from 'vue'
import BaseTimeline from '~/components/BaseTimeline.vue'
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'
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
const route = useRoute()
const notifications = ref([])
const isLoadingMessage = ref(false)
const selectedTab = ref(
['all', 'unread', 'control'].includes(route.query.tab) ? route.query.tab : 'unread',
)
@@ -528,234 +533,6 @@ 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',
LOTTERY_WIN: 'fas fa-trophy',
LOTTERY_DRAW: 'fas fa-bullhorn',
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)
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 fetchPrefs = async () => {
notificationPrefs.value = await fetchNotificationPreferences()
}
@@ -842,7 +619,7 @@ const formatType = (t) => {
}
}
onMounted(() => {
onActivated(() => {
fetchNotifications()
fetchPrefs()
})
@@ -859,6 +636,8 @@ onMounted(() => {
.message-page {
background-color: var(--background-color);
overflow-x: hidden;
height: calc(100vh - var(--header-height));
overflow-y: auto;
}
.message-page-header {

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'

View File

@@ -232,7 +232,7 @@
</template>
<script setup>
import { ref, computed, onMounted, onBeforeUnmount, nextTick, watch } from 'vue'
import { ref, computed, onMounted, onBeforeUnmount, nextTick, watch, watchEffect } from 'vue'
import VueEasyLightbox from 'vue-easy-lightbox'
import { useRoute } from 'vue-router'
import CommentItem from '~/components/CommentItem.vue'
@@ -268,7 +268,6 @@ const postReactions = ref([])
const comments = ref([])
const status = ref('PUBLISHED')
const pinnedAt = ref(null)
const isWaitingFetchingPost = ref(false)
const isWaitingPostingComment = ref(false)
const postTime = ref('')
const postItems = ref([])
@@ -392,7 +391,7 @@ const mapComment = (c, parentUserName = '', level = 0) => ({
avatar: c.author.avatar,
text: c.content,
reactions: c.reactions || [],
pinned: !!c.pinnedAt,
pinned: Boolean(c.pinned ?? c.pinnedAt ?? c.pinned_at),
reply: (c.replies || []).map((r) => mapComment(r, c.author.username, level + 1)),
openReplies: level === 0,
src: c.author.avatar,
@@ -455,38 +454,41 @@ const onCommentDeleted = (id) => {
fetchComments()
}
const fetchPost = async () => {
try {
isWaitingFetchingPost.value = true
const token = getToken()
const res = await fetch(`${API_BASE_URL}/api/posts/${postId}`, {
headers: { Authorization: token ? `Bearer ${token}` : '' },
})
isWaitingFetchingPost.value = false
if (!res.ok) {
if (res.status === 404 && process.client) {
router.replace('/404')
}
return
}
const data = await res.json()
postContent.value = data.content
author.value = data.author
title.value = data.title
category.value = data.category
tags.value = data.tags || []
postReactions.value = data.reactions || []
subscribed.value = !!data.subscribed
status.value = data.status
pinnedAt.value = data.pinnedAt
postTime.value = TimeManager.format(data.createdAt)
lottery.value = data.lottery || null
if (lottery.value && lottery.value.endTime) startCountdown()
await nextTick()
} catch (e) {
console.error(e)
}
}
const {
data: postData,
pending: pendingPost,
error: postError,
refresh: refreshPost,
} = await useAsyncData(`post-${postId}`, () => $fetch(`${API_BASE_URL}/api/posts/${postId}`), {
server: true,
lazy: false,
})
// 用 pendingPost 驱动现有 UI替代 isWaitingFetchingPost 手控)
const isWaitingFetchingPost = computed(() => pendingPost.value)
// 同步到现有的响应式字段
watchEffect(() => {
const data = postData.value
if (!data) return
postContent.value = data.content
author.value = data.author
title.value = data.title
category.value = data.category
tags.value = data.tags || []
postReactions.value = data.reactions || []
subscribed.value = !!data.subscribed
status.value = data.status
pinnedAt.value = data.pinnedAt
postTime.value = TimeManager.format(data.createdAt)
lottery.value = data.lottery || null
if (lottery.value && lottery.value.endTime) startCountdown()
})
// 404 客户端跳转
// if (postError.value?.statusCode === 404 && process.client) {
// router.replace('/404')
// }
const totalPosts = computed(() => comments.value.length + 1)
const lastReplyTime = computed(() =>
@@ -607,6 +609,7 @@ const approvePost = async () => {
if (res.ok) {
status.value = 'PUBLISHED'
toast.success('已通过审核')
await refreshPost()
} else {
toast.error('操作失败')
}
@@ -620,8 +623,8 @@ const pinPost = async () => {
headers: { Authorization: `Bearer ${token}` },
})
if (res.ok) {
pinnedAt.value = new Date().toISOString()
toast.success('已置顶')
await refreshPost()
} else {
toast.error('操作失败')
}
@@ -635,8 +638,8 @@ const unpinPost = async () => {
headers: { Authorization: `Bearer ${token}` },
})
if (res.ok) {
pinnedAt.value = null
toast.success('已取消置顶')
await refreshPost()
} else {
toast.error('操作失败')
}
@@ -674,6 +677,7 @@ const rejectPost = async () => {
if (res.ok) {
status.value = 'REJECTED'
toast.success('已驳回')
await refreshPost()
} else {
toast.error('操作失败')
}
@@ -709,7 +713,7 @@ const joinLottery = async () => {
})
if (res.ok) {
toast.success('已参与抽奖')
await fetchPost()
await refreshPost()
} else {
toast.error('操作失败')
}
@@ -780,9 +784,8 @@ onMounted(async () => {
window.addEventListener('scroll', updateCurrentIndex)
jumpToHashComment()
})
await fetchPost()
</script>
<style>
.post-page-container {
background-color: var(--background-color);
@@ -864,6 +867,7 @@ await fetchPost()
direction: ltr;
height: 300px;
width: 2px;
appearance: none;
-webkit-appearance: none;
background: transparent;
}

View File

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

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

@@ -24,6 +24,7 @@ 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

View File

@@ -1,5 +1,14 @@
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'
@@ -86,7 +95,11 @@ 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>`
},
})

View File

@@ -1,10 +1,32 @@
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
@@ -87,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

@@ -14,19 +14,46 @@ export const themeState = reactive({
})
function apply(mode) {
if (!process.client) return
if (!import.meta.client) return
const root = document.documentElement
if (mode === ThemeMode.SYSTEM) {
root.dataset.theme = window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light'
let newMode =
mode === ThemeMode.SYSTEM
? window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light'
: mode
if (root.dataset.theme === newMode) return
root.dataset.theme = newMode
// 更新 meta 标签
const androidMeta = document.querySelector('meta[name="theme-color"]')
const iosMeta = document.querySelector('meta[name="apple-mobile-web-app-status-bar-style"]')
const themeColor = getComputedStyle(document.documentElement)
.getPropertyValue('--background-color')
.trim()
const themeStatus = newMode === 'dark' ? 'black-translucent' : 'default'
if (androidMeta) {
androidMeta.content = themeColor
} else {
root.dataset.theme = mode
const newAndroidMeta = document.createElement('meta')
newAndroidMeta.name = 'theme-color'
newAndroidMeta.content = themeColor
document.head.appendChild(newAndroidMeta)
}
if (iosMeta) {
iosMeta.content = themeStatus
} else {
const newIosMeta = document.createElement('meta')
newIosMeta.name = 'apple-mobile-web-app-status-bar-style'
newIosMeta.content = themeStatus
document.head.appendChild(newIosMeta)
}
}
export function initTheme() {
if (!process.client) return
if (!import.meta.client) return
const saved = localStorage.getItem(THEME_KEY)
if (saved && Object.values(ThemeMode).includes(saved)) {
themeState.mode = saved
@@ -35,15 +62,117 @@ export function initTheme() {
}
export function setTheme(mode) {
if (!process.client) return
if (!import.meta.client) return
if (!Object.values(ThemeMode).includes(mode)) return
themeState.mode = mode
localStorage.setItem(THEME_KEY, mode)
apply(mode)
}
export function cycleTheme() {
if (!process.client) return
function getCircle(event) {
if (!import.meta.client) return undefined
let x, y
if (event.touches?.length) {
x = event.touches[0].clientX
y = event.touches[0].clientY
} else if (event.changedTouches?.length) {
x = event.changedTouches[0].clientX
y = event.changedTouches[0].clientY
} else {
x = event.clientX
y = event.clientY
}
return {
x,
y,
radius: Math.hypot(Math.max(x, window.innerWidth - x), Math.max(y, window.innerHeight - y)),
}
}
function withViewTransition(event, applyFn, direction = true) {
if (typeof document !== 'undefined' && document.startViewTransition) {
const transition = document.startViewTransition(async () => {
applyFn()
await nextTick()
})
transition.ready
.then(() => {
const { x, y, radius } = getCircle(event)
const clipPath = [`circle(0 at ${x}px ${y}px)`, `circle(${radius}px at ${x}px ${y}px)`]
document.documentElement.animate(
{
clipPath: direction ? clipPath : [...clipPath].reverse(),
},
{
duration: 400,
easing: 'ease-in-out',
pseudoElement: direction
? '::view-transition-new(root)'
: '::view-transition-old(root)',
},
)
})
.catch(console.warn)
} else {
fallbackThemeTransition(applyFn)
}
}
function fallbackThemeTransition(applyFn) {
if (!import.meta.client) return
const root = document.documentElement
const computedStyle = getComputedStyle(root)
// 获取当前背景色用于过渡
const currentBg = computedStyle.getPropertyValue('--background-color').trim()
// 创建过渡元素
const transitionElement = document.createElement('div')
transitionElement.style.cssText = `
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: ${currentBg};
z-index: 9999;
pointer-events: none;
backdrop-filter: blur(1px);
`
document.body.appendChild(transitionElement)
// 使用 Web Animations API 实现淡出动画
const animation = transitionElement.animate([{ opacity: 1 }, { opacity: 0 }], {
duration: 300,
easing: 'ease-out',
})
// 应用主题变更
applyFn()
// 动画完成后清理
animation.finished
.then(() => {
document.body.removeChild(transitionElement)
})
.catch(() => {
// 降级处理
document.body.removeChild(transitionElement)
})
}
function getSystemTheme() {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
}
export function cycleTheme(event) {
if (!import.meta.client) return
const modes = [ThemeMode.SYSTEM, ThemeMode.LIGHT, ThemeMode.DARK]
const index = modes.indexOf(themeState.mode)
const next = modes[(index + 1) % modes.length]
@@ -54,10 +183,31 @@ export function cycleTheme() {
} else {
toast.success('🌙 已经切换到暗色主题')
}
setTheme(next)
// 获取当前真实主题
const currentTheme = themeState.mode === ThemeMode.SYSTEM ? getSystemTheme() : themeState.mode
// 获取新主题的真实表现
const nextTheme = next === ThemeMode.SYSTEM ? getSystemTheme() : next
// 如果新旧主题相同,不用过渡动画
if (currentTheme === nextTheme) {
setTheme(next)
return
}
// 计算新主题是否是暗色
const newThemeIsDark = nextTheme === 'dark'
withViewTransition(
event,
() => {
setTheme(next)
},
!newThemeIsDark,
)
}
if (process.client && window.matchMedia) {
if (import.meta.client && window.matchMedia) {
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
if (themeState.mode === ThemeMode.SYSTEM) {
apply(ThemeMode.SYSTEM)