mirror of
https://github.com/nagisa77/OpenIsle.git
synced 2026-06-17 11:14:23 +00:00
Compare commits
124 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9b4c36c76a | |||
| edfc81aeb0 | |||
| 7bd1225b27 | |||
| 2dd56e27af | |||
| c3ecef3609 | |||
| efc74d0f77 | |||
| f27cb5c703 | |||
| a756c2fab3 | |||
| 4e2171a8a6 | |||
| bcbdff8768 | |||
| b976a1f46f | |||
| b9fd9711de | |||
| 642a527dcf | |||
| 88afcc5a8e | |||
| 2c5462cd97 | |||
| 2f29946b11 | |||
| e27aa34cfd | |||
| 2322b2da15 | |||
| 79261054f9 | |||
| 86633e1f21 | |||
| 784598a6f0 | |||
| fdad0e5d34 | |||
| ebf63c4072 | |||
| 354d6bdaf9 | |||
| d9aebdebdc | |||
| d6f6495b35 | |||
| 300f8705ef | |||
| 1f74a29dce | |||
| 27ef792b11 | |||
| 8dd2d59617 | |||
| 077ba448d7 | |||
| 9ce85f2769 | |||
| f5557cbf08 | |||
| e042c499e1 | |||
| e01afb168c | |||
| c1d81eb1d1 | |||
| 2b0b429866 | |||
| 8ea85d78ee | |||
| 3b506fe8a8 | |||
| 3cc7a4c01a | |||
| 2e749a5672 | |||
| 7d553d7750 | |||
| 16105cef54 | |||
| 2b824d94f2 | |||
| 00d3c563e2 | |||
| b26891261c | |||
| c1d19b854b | |||
| 72e7ccf262 | |||
| 84ca6fd28c | |||
| d1c148c5c4 | |||
| ef58630dae | |||
| f025e82e7c | |||
| 4380a988f7 | |||
| 2899f7af48 | |||
| d4b05256a3 | |||
| 57a26e375d | |||
| 8a202c4fba | |||
| 089b2a3f5f | |||
| 0b3d7a21d5 | |||
| fe8a705a28 | |||
| 974c7ba83e | |||
| f2937d735d | |||
| 423248c574 | |||
| 5126cfda8c | |||
| e009875797 | |||
| 04ff17f796 | |||
| e9c9fbd742 | |||
| b385945c2d | |||
| 24cbed2eda | |||
| ba073b71a6 | |||
| 5ff098ea21 | |||
| f6713b956e | |||
| b8ea12646f | |||
| e573e54c2b | |||
| 8ec005d392 | |||
| b1f92f61a6 | |||
| 824b4dd8aa | |||
| 6b08db7e58 | |||
| 6f3830b3f7 | |||
| d70dad723f | |||
| 2cf89e4802 | |||
| 1fc6460ae0 | |||
| a04e5c2f6f | |||
| 77b26937f5 | |||
| a1134b9d4b | |||
| 600f6ac1d1 | |||
| 9ad50b35c9 | |||
| 867ee3907b | |||
| 58fcd42745 | |||
| 0ee62a3a04 | |||
| f0bc7a22a0 | |||
| f6c0c8e226 | |||
| 8f3c0d6710 | |||
| 4f738778db | |||
| 84b45f785d | |||
| df56d7e885 | |||
| 76176e135c | |||
| ab87e0e51c | |||
| 5346a063bf | |||
| e53f2130b8 | |||
| 1e87e9252d | |||
| 3fc4d29dce | |||
| bcdac9d9b2 | |||
| ea9710d16f | |||
| 47134cadc2 | |||
| 1a1b20b9cf | |||
| b63ebb8fae | |||
| e0f7299a86 | |||
| 1f9ae8d057 | |||
| da1ad73cf6 | |||
| 53c603f33a | |||
| 06f86f2b21 | |||
| 22693bfdd9 | |||
| 0058f20b1e | |||
| 304d941d68 | |||
| 3dbcd2ac4d | |||
| 2efe4e733a | |||
| 08239a16b8 | |||
| cb49dc9b73 | |||
| 43d4c9be43 | |||
| 1dc13698ad | |||
| d58432dcd9 | |||
| e7ff73c7f9 | |||
| 4ee9532d5f |
@@ -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
|
||||||
|
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
name: CI & CD
|
name: CI & CD
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
schedule:
|
||||||
|
- cron: "0 19 * * *" # 每天 UTC 19:00,相当于北京时间凌晨3点
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-and-deploy:
|
build-and-deploy:
|
||||||
@@ -13,22 +13,6 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- 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
|
- name: Deploy to Server
|
||||||
uses: appleboy/ssh-action@v1.0.3
|
uses: appleboy/ssh-action@v1.0.3
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
|
|
||||||
OpenIsle 是一个使用 Spring Boot 和 Vue 3 构建的全栈开源社区平台,提供用户注册、登录、贴文发布、评论交互等完整功能,可用于项目社区或直接打造自主社区站点。
|
OpenIsle 是一个使用 Spring Boot 和 Vue 3 构建的全栈开源社区平台,提供用户注册、登录、贴文发布、评论交互等完整功能,可用于项目社区或直接打造自主社区站点。
|
||||||
|
|
||||||
## 🚀 部署
|
## 🚧 开发
|
||||||
|
|
||||||
### 后端
|
### 后端
|
||||||
|
|
||||||
@@ -20,9 +20,26 @@ OpenIsle 是一个使用 Spring Boot 和 Vue 3 构建的全栈开源社区平台
|
|||||||
|
|
||||||
### 前端
|
### 前端
|
||||||
|
|
||||||
1. `cd open-isle-cli`
|
1. 进入前端目录
|
||||||
2. 执行 `npm install`
|
```bash
|
||||||
3. `npm run serve`可在本地启动开发服务,产品环境使用 `npm run build`生成 `dist/` 文件,配合线上网站方式部署
|
cd frontend_nuxt
|
||||||
|
```
|
||||||
|
2. 安装依赖
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
3. 启动开发服务
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
生产版本使用如下命令编译:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
会在 `.output` 目录生成文件,配合线上网站方式部署
|
||||||
|
|
||||||
## ✨ 项目特点
|
## ✨ 项目特点
|
||||||
|
|
||||||
|
|||||||
@@ -75,14 +75,16 @@ public class SecurityConfig {
|
|||||||
cfg.setAllowedOrigins(List.of(
|
cfg.setAllowedOrigins(List.of(
|
||||||
"http://127.0.0.1:8080",
|
"http://127.0.0.1:8080",
|
||||||
"http://127.0.0.1:3000",
|
"http://127.0.0.1:3000",
|
||||||
|
"http://127.0.0.1:3001",
|
||||||
"http://127.0.0.1",
|
"http://127.0.0.1",
|
||||||
"http://localhost:8080",
|
"http://localhost:8080",
|
||||||
"http://localhost:3000",
|
"http://localhost:3000",
|
||||||
|
"http://localhost:3001",
|
||||||
"http://localhost",
|
"http://localhost",
|
||||||
"http://30.211.97.238:3000",
|
"http://30.211.97.238:3000",
|
||||||
"http://30.211.97.238",
|
"http://30.211.97.238",
|
||||||
"http://192.168.7.70",
|
"http://192.168.7.98",
|
||||||
"http://192.168.7.70:8080",
|
"http://192.168.7.98:3000",
|
||||||
websiteUrl,
|
websiteUrl,
|
||||||
websiteUrl.replace("://www.", "://")
|
websiteUrl.replace("://www.", "://")
|
||||||
));
|
));
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import lombok.RequiredArgsConstructor;
|
|||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@@ -44,8 +45,11 @@ public class CategoryController {
|
|||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
public List<CategoryDto> list() {
|
public List<CategoryDto> list() {
|
||||||
return categoryService.listCategories().stream()
|
List<Category> all = categoryService.listCategories();
|
||||||
.map(c -> categoryMapper.toDto(c, postService.countPostsByCategory(c.getId())))
|
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()))
|
.sorted((a, b) -> Long.compare(b.getCount(), a.getCount()))
|
||||||
.collect(Collectors.toList());
|
.collect(Collectors.toList());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import lombok.RequiredArgsConstructor;
|
|||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@@ -62,8 +63,11 @@ public class TagController {
|
|||||||
@GetMapping
|
@GetMapping
|
||||||
public List<TagDto> list(@RequestParam(value = "keyword", required = false) String keyword,
|
public List<TagDto> list(@RequestParam(value = "keyword", required = false) String keyword,
|
||||||
@RequestParam(value = "limit", required = false) Integer limit) {
|
@RequestParam(value = "limit", required = false) Integer limit) {
|
||||||
List<TagDto> dtos = tagService.searchTags(keyword).stream()
|
List<Tag> tags = tagService.searchTags(keyword);
|
||||||
.map(t -> tagMapper.toDto(t, postService.countPostsByTag(t.getId())))
|
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()))
|
.sorted((a, b) -> Long.compare(b.getCount(), a.getCount()))
|
||||||
.collect(Collectors.toList());
|
.collect(Collectors.toList());
|
||||||
if (limit != null && limit > 0 && dtos.size() > limit) {
|
if (limit != null && limit > 0 && dtos.size() > limit) {
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ public class Notification {
|
|||||||
private Long id;
|
private Long id;
|
||||||
|
|
||||||
@Enumerated(EnumType.STRING)
|
@Enumerated(EnumType.STRING)
|
||||||
@Column(nullable = false)
|
@Column(nullable = false, length = 50)
|
||||||
private NotificationType type;
|
private NotificationType type;
|
||||||
|
|
||||||
@ManyToOne(fetch = FetchType.LAZY, optional = false)
|
@ManyToOne(fetch = FetchType.LAZY, optional = false)
|
||||||
|
|||||||
@@ -92,8 +92,14 @@ public interface PostRepository extends JpaRepository<Post, Long> {
|
|||||||
|
|
||||||
long countByCategory_Id(Long categoryId);
|
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);
|
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);
|
long countByAuthor_Id(Long userId);
|
||||||
|
|
||||||
@Query("SELECT FUNCTION('date', p.createdAt) AS d, COUNT(p) AS c FROM Post p " +
|
@Query("SELECT FUNCTION('date', p.createdAt) AS d, COUNT(p) AS c FROM Post p " +
|
||||||
|
|||||||
@@ -31,16 +31,15 @@ import com.openisle.service.EmailSender;
|
|||||||
|
|
||||||
import java.time.ZoneId;
|
import java.time.ZoneId;
|
||||||
import java.time.ZoneOffset;
|
import java.time.ZoneOffset;
|
||||||
import java.util.List;
|
import java.util.*;
|
||||||
|
|
||||||
import org.springframework.data.domain.PageRequest;
|
import org.springframework.data.domain.PageRequest;
|
||||||
import org.springframework.data.domain.Pageable;
|
import org.springframework.data.domain.Pageable;
|
||||||
import org.springframework.data.domain.Sort;
|
import org.springframework.data.domain.Sort;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import org.springframework.util.CollectionUtils;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
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.ConcurrentHashMap;
|
||||||
import java.util.concurrent.ConcurrentMap;
|
import java.util.concurrent.ConcurrentMap;
|
||||||
import java.util.concurrent.ScheduledFuture;
|
import java.util.concurrent.ScheduledFuture;
|
||||||
@@ -567,10 +566,31 @@ public class PostService {
|
|||||||
return postRepository.countByCategory_Id(categoryId);
|
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) {
|
public long countPostsByTag(Long tagId) {
|
||||||
return postRepository.countDistinctByTags_Id(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) {
|
private java.util.List<Post> sortByPinnedAndCreated(java.util.List<Post> posts) {
|
||||||
return posts.stream()
|
return posts.stream()
|
||||||
.sorted(java.util.Comparator
|
.sorted(java.util.Comparator
|
||||||
|
|||||||
@@ -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_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_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_GITHUB_CLIENT_ID=Ov23liVkO1NPAX5JyWxJ
|
||||||
NUXT_PUBLIC_DISCORD_CLIENT_ID=1394985417044000779
|
NUXT_PUBLIC_DISCORD_CLIENT_ID=1394985417044000779
|
||||||
NUXT_PUBLIC_TWITTER_CLIENT_ID=ZTRTU05KSk9KTTJrTTdrVC1tc1E6MTpjaQ
|
NUXT_PUBLIC_TWITTER_CLIENT_ID=ZTRTU05KSk9KTTJrTTdrVC1tc1E6MTpjaQ
|
||||||
|
|||||||
@@ -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
|
||||||
+71
-38
@@ -15,62 +15,77 @@
|
|||||||
<div class="content" :class="{ 'menu-open': menuVisible && !hideMenu }">
|
<div class="content" :class="{ 'menu-open': menuVisible && !hideMenu }">
|
||||||
<NuxtPage keepalive />
|
<NuxtPage keepalive />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-if="showNewPostIcon && isMobile" class="app-new-post-icon" @click="goToNewPost">
|
||||||
|
<i class="fas fa-edit"></i>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<GlobalPopups />
|
<GlobalPopups />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup>
|
||||||
import HeaderComponent from '~/components/HeaderComponent.vue'
|
import HeaderComponent from '~/components/HeaderComponent.vue'
|
||||||
import MenuComponent from '~/components/MenuComponent.vue'
|
import MenuComponent from '~/components/MenuComponent.vue'
|
||||||
|
|
||||||
import GlobalPopups from '~/components/GlobalPopups.vue'
|
import GlobalPopups from '~/components/GlobalPopups.vue'
|
||||||
|
import { useIsMobile } from '~/utils/screen'
|
||||||
|
|
||||||
export default {
|
const isMobile = useIsMobile()
|
||||||
name: 'App',
|
const menuVisible = ref(!isMobile.value)
|
||||||
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 header = useTemplateRef('header')
|
const showNewPostIcon = computed(() => useRoute().path === '/')
|
||||||
|
|
||||||
onMounted(() => {
|
const hideMenu = computed(() => {
|
||||||
if (typeof window !== 'undefined') {
|
return [
|
||||||
menuVisible.value = window.innerWidth > 768
|
'/login',
|
||||||
}
|
'/signup',
|
||||||
})
|
'/404',
|
||||||
|
'/signup-reason',
|
||||||
|
'/github-callback',
|
||||||
|
'/twitter-callback',
|
||||||
|
'/discord-callback',
|
||||||
|
'/forgot-password',
|
||||||
|
'/google-callback',
|
||||||
|
].includes(useRoute().path)
|
||||||
|
})
|
||||||
|
|
||||||
const handleMenuOutside = (event) => {
|
const header = useTemplateRef('header')
|
||||||
const btn = header.value.$refs.menuBtn
|
|
||||||
if (btn && (btn === event.target || btn.contains(event.target))) {
|
|
||||||
return // 如果是菜单按钮的点击,不处理关闭
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isMobile.value) {
|
onMounted(() => {
|
||||||
menuVisible.value = false
|
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>
|
</script>
|
||||||
|
|
||||||
<style src="~/assets/global.css"></style>
|
<style src="~/assets/global.css"></style>
|
||||||
<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 {
|
.header-container {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0;
|
top: 0;
|
||||||
@@ -103,6 +118,24 @@ export default {
|
|||||||
margin: 0 auto;
|
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: var(--blur-5);
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.content,
|
.content,
|
||||||
.content.menu-open {
|
.content.menu-open {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -2,27 +2,35 @@
|
|||||||
--primary-color-hover: rgb(9, 95, 105);
|
--primary-color-hover: rgb(9, 95, 105);
|
||||||
--primary-color: rgb(10, 110, 120);
|
--primary-color: rgb(10, 110, 120);
|
||||||
--primary-color-disabled: rgba(93, 152, 156, 0.5);
|
--primary-color-disabled: rgba(93, 152, 156, 0.5);
|
||||||
|
--new-post-icon-color: rgba(10, 111, 120, 0.598);
|
||||||
--header-height: 60px;
|
--header-height: 60px;
|
||||||
--header-background-color: white;
|
--header-background-color: white;
|
||||||
--header-border-color: lightgray;
|
--header-border-color: lightgray;
|
||||||
--header-text-color: black;
|
--header-text-color: black;
|
||||||
--menu-background-color: white;
|
--blur-1: blur(1px);
|
||||||
|
--blur-2: blur(2px);
|
||||||
|
--blur-4: blur(4px);
|
||||||
|
--blur-5: blur(5px);
|
||||||
|
--blur-10: blur(10px);
|
||||||
|
/* 加一个app前缀防止与firefox的userChrome.css中的--menu-background-color冲突 */
|
||||||
|
--app-menu-background-color: white;
|
||||||
--background-color: white;
|
--background-color: white;
|
||||||
/* --background-color-blur: rgba(255, 255, 255, 0.57); */
|
--background-color-blur: rgba(255, 255, 255, 0.57);
|
||||||
--background-color-blur: var(--background-color);
|
|
||||||
--menu-border-color: lightgray;
|
--menu-border-color: lightgray;
|
||||||
--normal-border-color: lightgray;
|
--normal-border-color: lightgray;
|
||||||
--menu-selected-background-color: rgba(208, 250, 255, 0.659);
|
--menu-selected-background-color: rgba(208, 250, 255, 0.659);
|
||||||
--menu-text-color: black;
|
--menu-text-color: black;
|
||||||
--scroller-background-color: rgba(130, 175, 180, 0.5);
|
--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);
|
--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: rgb(248, 248, 248);
|
||||||
--login-background-color-hover: #e0e0e0;
|
--login-background-color-hover: #e0e0e0;
|
||||||
--text-color: black;
|
--text-color: black;
|
||||||
--blockquote-text-color: #6a737d;
|
--blockquote-text-color: #6a737d;
|
||||||
--menu-width: 200px;
|
--menu-width: 200px;
|
||||||
--page-max-width: 1200px;
|
--page-max-width: 1400px;
|
||||||
--page-max-width-mobile: 900px;
|
--page-max-width-mobile: 900px;
|
||||||
--article-info-background-color: #f0f0f0;
|
--article-info-background-color: #f0f0f0;
|
||||||
--activity-card-background-color: #fafafa;
|
--activity-card-background-color: #fafafa;
|
||||||
@@ -33,8 +41,9 @@
|
|||||||
--header-border-color: #555;
|
--header-border-color: #555;
|
||||||
--primary-color: rgb(17, 182, 197);
|
--primary-color: rgb(17, 182, 197);
|
||||||
--primary-color-hover: rgb(13, 137, 151);
|
--primary-color-hover: rgb(13, 137, 151);
|
||||||
|
--new-post-icon-color: rgba(10, 111, 120, 0.598);
|
||||||
--header-text-color: white;
|
--header-text-color: white;
|
||||||
--menu-background-color: #333;
|
--app-menu-background-color: #333;
|
||||||
--background-color: #333;
|
--background-color: #333;
|
||||||
/* --background-color-blur: #333333a4; */
|
/* --background-color-blur: #333333a4; */
|
||||||
--background-color-blur: var(--background-color);
|
--background-color-blur: var(--background-color);
|
||||||
@@ -42,8 +51,10 @@
|
|||||||
--normal-border-color: #555;
|
--normal-border-color: #555;
|
||||||
--menu-selected-background-color: rgba(255, 255, 255, 0.1);
|
--menu-selected-background-color: rgba(255, 255, 255, 0.1);
|
||||||
--menu-text-color: white;
|
--menu-text-color: white;
|
||||||
--normal-background-color: #000000;
|
/* --normal-background-color: #000000; */
|
||||||
|
--normal-background-color: #333;
|
||||||
--lottery-background-color: #4e4e4e;
|
--lottery-background-color: #4e4e4e;
|
||||||
|
--code-highlight-background-color: #262b35;
|
||||||
--login-background-color: #575757;
|
--login-background-color: #575757;
|
||||||
--login-background-color-hover: #717171;
|
--login-background-color-hover: #717171;
|
||||||
--text-color: #eee;
|
--text-color: #eee;
|
||||||
@@ -52,6 +63,15 @@
|
|||||||
--activity-card-background-color: #585858;
|
--activity-card-background-color: #585858;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:root[data-frosted='off'] {
|
||||||
|
--blur-1: none;
|
||||||
|
--blur-2: none;
|
||||||
|
--blur-4: none;
|
||||||
|
--blur-5: none;
|
||||||
|
--blur-10: none;
|
||||||
|
--background-color-blur: var(--background-color);
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -131,13 +151,43 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.info-content-text pre {
|
.info-content-text pre {
|
||||||
background-color: var(--normal-background-color);
|
display: flex;
|
||||||
|
background-color: var(--code-highlight-background-color);
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
position: relative;
|
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 {
|
.copy-code-btn {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 4px;
|
top: 4px;
|
||||||
@@ -156,20 +206,13 @@ body {
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-content-text code {
|
.about-content a,
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-content-text a {
|
.info-content-text a {
|
||||||
color: var(--primary-color);
|
color: var(--primary-color);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.about-content a:hover,
|
||||||
.info-content-text a:hover {
|
.info-content-text a:hover {
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
@@ -267,7 +310,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.info-content-text pre {
|
.info-content-text pre {
|
||||||
line-height: 1.1;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.vditor-panel {
|
.vditor-panel {
|
||||||
@@ -276,6 +319,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 styles */
|
||||||
#nprogress {
|
#nprogress {
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
|
|||||||
@@ -41,8 +41,8 @@ export default {
|
|||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
backdrop-filter: blur(2px);
|
backdrop-filter: var(--blur-2);
|
||||||
-webkit-backdrop-filter: blur(2px);
|
-webkit-backdrop-filter: var(--blur-2);
|
||||||
}
|
}
|
||||||
.popup-content {
|
.popup-content {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|||||||
@@ -114,7 +114,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { ref, computed, watch, onMounted } from 'vue'
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
import { useIsMobile } from '~/utils/screen'
|
import { useIsMobile } from '~/utils/screen'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
@@ -312,7 +312,7 @@ export default {
|
|||||||
border: none;
|
border: none;
|
||||||
outline: none;
|
outline: none;
|
||||||
margin-left: 5px;
|
margin-left: 5px;
|
||||||
background-color: var(--menu-background-color);
|
background-color: var(--app-menu-background-color);
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -352,7 +352,7 @@ export default {
|
|||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
background-color: var(--menu-background-color);
|
background-color: var(--app-menu-background-color);
|
||||||
z-index: 1300;
|
z-index: 1300;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -3,22 +3,24 @@
|
|||||||
<div class="dropdown-trigger" @click="toggle">
|
<div class="dropdown-trigger" @click="toggle">
|
||||||
<slot name="trigger"></slot>
|
<slot name="trigger"></slot>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="visible" class="dropdown-menu-container">
|
<Transition name="dropdown-menu">
|
||||||
<div
|
<div v-if="visible" class="dropdown-menu-container">
|
||||||
v-for="(item, idx) in items"
|
<div
|
||||||
:key="idx"
|
v-for="(item, idx) in items"
|
||||||
class="dropdown-item"
|
:key="idx"
|
||||||
:style="{ color: item.color || 'inherit' }"
|
class="dropdown-item"
|
||||||
@click="handle(item)"
|
:style="{ color: item.color || 'inherit' }"
|
||||||
>
|
@click="handle(item)"
|
||||||
{{ item.text }}
|
>
|
||||||
|
{{ item.text }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Transition>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { ref, onMounted, onBeforeUnmount } from 'vue'
|
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
||||||
export default {
|
export default {
|
||||||
name: 'DropdownMenu',
|
name: 'DropdownMenu',
|
||||||
props: {
|
props: {
|
||||||
@@ -61,17 +63,28 @@ export default {
|
|||||||
position: relative;
|
position: relative;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dropdown-trigger {
|
.dropdown-trigger {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
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 {
|
.dropdown-menu-container {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 100%;
|
top: 100%;
|
||||||
right: 0;
|
right: 0;
|
||||||
background-color: var(--menu-background-color);
|
background-color: var(--app-menu-background-color);
|
||||||
border: 1px solid var(--normal-border-color);
|
border: 1px solid var(--normal-border-color);
|
||||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
|
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
@@ -82,7 +95,9 @@ export default {
|
|||||||
.dropdown-item {
|
.dropdown-item {
|
||||||
padding: 8px 16px;
|
padding: 8px 16px;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dropdown-item:hover {
|
.dropdown-item:hover {
|
||||||
background-color: var(--menu-selected-background-color);
|
background-color: var(--menu-selected-background-color);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
</button>
|
</button>
|
||||||
<span v-if="isMobile && unreadCount > 0" class="menu-unread-dot"></span>
|
<span v-if="isMobile && unreadCount > 0" class="menu-unread-dot"></span>
|
||||||
</div>
|
</div>
|
||||||
<NuxtLink class="logo-container" to="/" @click.prevent="goToHome">
|
<NuxtLink class="logo-container" :to="`/`" @click="refrechData">
|
||||||
<img
|
<img
|
||||||
alt="OpenIsle"
|
alt="OpenIsle"
|
||||||
src="https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/image.png"
|
src="https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/image.png"
|
||||||
@@ -20,11 +20,22 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ClientOnly>
|
<ClientOnly>
|
||||||
<div v-if="isLogin" class="header-content-right">
|
<div class="header-content-right">
|
||||||
<div v-if="isMobile" class="search-icon" @click="search">
|
<div v-if="isMobile" class="search-icon" @click="search">
|
||||||
<i class="fas fa-search"></i>
|
<i class="fas fa-search"></i>
|
||||||
</div>
|
</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>
|
<template #trigger>
|
||||||
<div class="avatar-container">
|
<div class="avatar-container">
|
||||||
<img class="avatar-img" :src="avatar" alt="avatar" />
|
<img class="avatar-img" :src="avatar" alt="avatar" />
|
||||||
@@ -32,14 +43,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else class="header-content-right">
|
<div v-if="!isLogin" class="auth-btns">
|
||||||
<div v-if="isMobile" class="search-icon" @click="search">
|
<div class="header-content-item-main" @click="goToLogin">登录</div>
|
||||||
<i class="fas fa-search"></i>
|
<div class="header-content-item-secondary" @click="goToSignup">注册</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-content-item-main" @click="goToLogin">登录</div>
|
|
||||||
<div class="header-content-item-secondary" @click="goToSignup">注册</div>
|
|
||||||
</div>
|
</div>
|
||||||
</ClientOnly>
|
</ClientOnly>
|
||||||
|
|
||||||
@@ -51,12 +59,14 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ClientOnly } from '#components'
|
import { ClientOnly } from '#components'
|
||||||
import { computed, nextTick, ref, watch } from 'vue'
|
import { computed, nextTick, ref, watch } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
import DropdownMenu from '~/components/DropdownMenu.vue'
|
import DropdownMenu from '~/components/DropdownMenu.vue'
|
||||||
|
import ToolTip from '~/components/ToolTip.vue'
|
||||||
import SearchDropdown from '~/components/SearchDropdown.vue'
|
import SearchDropdown from '~/components/SearchDropdown.vue'
|
||||||
import { authState, clearToken, loadCurrentUser } from '~/utils/auth'
|
import { authState, clearToken, loadCurrentUser } from '~/utils/auth'
|
||||||
import { fetchUnreadCount, notificationState } from '~/utils/notification'
|
import { fetchUnreadCount, notificationState } from '~/utils/notification'
|
||||||
import { useIsMobile } from '~/utils/screen'
|
import { useIsMobile } from '~/utils/screen'
|
||||||
|
import { themeState, cycleTheme, ThemeMode } from '~/utils/theme'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
showMenuBtn: {
|
showMenuBtn: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
@@ -67,20 +77,12 @@ const props = defineProps({
|
|||||||
const isLogin = computed(() => authState.loggedIn)
|
const isLogin = computed(() => authState.loggedIn)
|
||||||
const isMobile = useIsMobile()
|
const isMobile = useIsMobile()
|
||||||
const unreadCount = computed(() => notificationState.unreadCount)
|
const unreadCount = computed(() => notificationState.unreadCount)
|
||||||
const router = useRouter()
|
|
||||||
const avatar = ref('')
|
const avatar = ref('')
|
||||||
const showSearch = ref(false)
|
const showSearch = ref(false)
|
||||||
const searchDropdown = ref(null)
|
const searchDropdown = ref(null)
|
||||||
const userMenu = ref(null)
|
const userMenu = ref(null)
|
||||||
const menuBtn = 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 = () => {
|
const search = () => {
|
||||||
showSearch.value = true
|
showSearch.value = true
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
@@ -122,12 +124,33 @@ const goToLogout = () => {
|
|||||||
navigateTo('/login', { replace: true })
|
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(() => [
|
const headerMenuItems = computed(() => [
|
||||||
{ text: '设置', onClick: goToSettings },
|
{ text: '设置', onClick: goToSettings },
|
||||||
{ text: '个人主页', onClick: goToProfile },
|
{ text: '个人主页', onClick: goToProfile },
|
||||||
{ text: '退出', onClick: goToLogout },
|
{ 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 () => {
|
onMounted(async () => {
|
||||||
const updateAvatar = async () => {
|
const updateAvatar = async () => {
|
||||||
if (authState.loggedIn) {
|
if (authState.loggedIn) {
|
||||||
@@ -155,25 +178,17 @@ onMounted(async () => {
|
|||||||
await updateUnread()
|
await updateUnread()
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
watch(
|
|
||||||
() => router.currentRoute.value.fullPath,
|
|
||||||
() => {
|
|
||||||
if (userMenu.value) userMenu.value.close()
|
|
||||||
showSearch.value = false
|
|
||||||
},
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.header {
|
.header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
height: var(--header-height);
|
height: var(--header-height);
|
||||||
background-color: var(--background-color-blur);
|
background-color: var(--background-color-blur);
|
||||||
backdrop-filter: blur(10px);
|
backdrop-filter: var(--blur-10);
|
||||||
color: var(--header-text-color);
|
color: var(--header-text-color);
|
||||||
border-bottom: 1px solid var(--header-border-color);
|
border-bottom: 1px solid var(--header-border-color);
|
||||||
}
|
}
|
||||||
@@ -192,11 +207,10 @@ onMounted(async () => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
margin: 0 auto;
|
|
||||||
max-width: var(--page-max-width);
|
max-width: var(--page-max-width);
|
||||||
|
backdrop-filter: var(--blur-10);
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-content-left {
|
.header-content-left {
|
||||||
@@ -206,6 +220,14 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.header-content-right {
|
.header-content-right {
|
||||||
|
display: flex;
|
||||||
|
margin-left: auto;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-btns {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -287,7 +309,13 @@ onMounted(async () => {
|
|||||||
background-color: var(--menu-selected-background-color);
|
background-color: var(--menu-selected-background-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-icon {
|
.search-icon,
|
||||||
|
.theme-icon {
|
||||||
|
font-size: 18px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-post-icon {
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ const goLogin = () => {
|
|||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
backdrop-filter: blur(4px);
|
backdrop-filter: var(--blur-4);
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,168 +1,195 @@
|
|||||||
<template>
|
<template>
|
||||||
<transition name="slide">
|
<transition name="slide">
|
||||||
<nav v-if="visible" class="menu">
|
<nav v-if="visible" class="menu">
|
||||||
<div class="menu-item-container">
|
<div class="menu-content">
|
||||||
<NuxtLink class="menu-item" exact-active-class="selected" to="/" @click="handleHomeClick">
|
<div class="menu-item-container">
|
||||||
<i class="menu-item-icon fas fa-hashtag"></i>
|
<NuxtLink class="menu-item" exact-active-class="selected" to="/" @click="handleItemClick">
|
||||||
<span class="menu-item-text">话题</span>
|
<i class="menu-item-icon fas fa-hashtag"></i>
|
||||||
</NuxtLink>
|
<span class="menu-item-text">话题</span>
|
||||||
<NuxtLink
|
</NuxtLink>
|
||||||
class="menu-item"
|
<NuxtLink
|
||||||
exact-active-class="selected"
|
class="menu-item"
|
||||||
to="/message"
|
exact-active-class="selected"
|
||||||
@click="handleItemClick"
|
to="/new-post"
|
||||||
>
|
@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)"
|
|
||||||
>
|
>
|
||||||
<template v-if="c.smallIcon || c.icon">
|
<i class="menu-item-icon fas fa-edit"></i>
|
||||||
<img
|
<span class="menu-item-text">发帖</span>
|
||||||
v-if="isImageIcon(c.smallIcon || c.icon)"
|
</NuxtLink>
|
||||||
:src="c.smallIcon || c.icon"
|
<NuxtLink
|
||||||
class="section-item-icon"
|
class="menu-item"
|
||||||
:alt="c.name"
|
exact-active-class="selected"
|
||||||
/>
|
to="/message"
|
||||||
<i v-else :class="['section-item-icon', c.smallIcon || c.icon]"></i>
|
@click="handleItemClick"
|
||||||
</template>
|
>
|
||||||
<span class="section-item-text">
|
<i class="menu-item-icon fas fa-envelope"></i>
|
||||||
{{ c.name }}
|
<span class="menu-item-text">我的消息</span>
|
||||||
<span class="section-item-text-count" v-if="c.count >= 0">x {{ c.count }}</span>
|
<span v-if="unreadCount > 0" class="unread-container">
|
||||||
|
<span class="unread"> {{ showUnreadCount }} </span>
|
||||||
</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>
|
||||||
|
<NuxtLink
|
||||||
|
v-if="authState.loggedIn"
|
||||||
|
class="menu-item"
|
||||||
|
exact-active-class="selected"
|
||||||
|
to="/about/points"
|
||||||
|
@click="handleItemClick"
|
||||||
|
>
|
||||||
|
<i class="menu-item-icon fas fa-coins"></i>
|
||||||
|
<span class="menu-item-text">
|
||||||
|
积分商城
|
||||||
|
<span v-if="myPoint !== null" class="point-count">{{ myPoint }}</span>
|
||||||
|
</span>
|
||||||
|
</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="menu-section">
|
<div class="menu-section">
|
||||||
<div class="section-header" @click="tagOpen = !tagOpen">
|
<div class="section-header" @click="categoryOpen = !categoryOpen">
|
||||||
<span>tag</span>
|
<span>类别</span>
|
||||||
<i :class="tagOpen ? 'fas fa-chevron-up' : 'fas fa-chevron-down'"></i>
|
<i :class="categoryOpen ? '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>
|
||||||
<div v-else v-for="t in tagData" :key="t.id" class="section-item" @click="gotoTag(t)">
|
<div v-if="categoryOpen" class="section-items">
|
||||||
<img
|
<div v-if="isLoadingCategory" class="menu-loading-container">
|
||||||
v-if="isImageIcon(t.smallIcon || t.icon)"
|
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
|
||||||
:src="t.smallIcon || t.icon"
|
</div>
|
||||||
class="section-item-icon"
|
<div
|
||||||
:alt="t.name"
|
v-else
|
||||||
/>
|
v-for="c in categoryData"
|
||||||
<i v-else class="section-item-icon fas fa-hashtag"></i>
|
:key="c.id"
|
||||||
<span class="section-item-text"
|
class="section-item"
|
||||||
>{{ t.name }} <span class="section-item-text-count">x {{ t.count }}</span></span
|
@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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="menu-footer">
|
<!-- 解决动态样式的水合错误 -->
|
||||||
<div class="menu-footer-btn" @click="cycleTheme">
|
<ClientOnly v-if="!isMobile">
|
||||||
<i :class="iconClass"></i>
|
<div class="menu-footer">
|
||||||
|
<div class="menu-footer-btn" @click="cycleTheme">
|
||||||
|
<i :class="iconClass"></i>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</ClientOnly>
|
||||||
</nav>
|
</nav>
|
||||||
</transition>
|
</transition>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, watch, onMounted } from 'vue'
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
import { themeState, cycleTheme, ThemeMode } from '~/utils/theme'
|
import { authState, fetchCurrentUser } from '~/utils/auth'
|
||||||
import { authState } from '~/utils/auth'
|
|
||||||
import { fetchUnreadCount, notificationState } from '~/utils/notification'
|
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 config = useRuntimeConfig()
|
||||||
const API_BASE_URL = config.public.apiBaseUrl
|
const API_BASE_URL = config.public.apiBaseUrl
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
visible: {
|
visible: { type: Boolean, default: true },
|
||||||
type: Boolean,
|
|
||||||
default: true,
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['item-click'])
|
const emit = defineEmits(['item-click'])
|
||||||
|
|
||||||
const categoryOpen = ref(true)
|
const categoryOpen = ref(true)
|
||||||
const tagOpen = ref(true)
|
const tagOpen = ref(true)
|
||||||
const isLoadingCategory = ref(false)
|
const myPoint = ref(null)
|
||||||
const isLoadingTag = ref(false)
|
|
||||||
const categoryData = ref([])
|
|
||||||
const tagData = ref([])
|
|
||||||
|
|
||||||
const fetchCategoryData = async () => {
|
/** ✅ 用 useAsyncData 替换原生 fetch,避免 SSR+CSR 二次请求 */
|
||||||
isLoadingCategory.value = true
|
const {
|
||||||
const res = await fetch(`${API_BASE_URL}/api/categories`)
|
data: categoryData,
|
||||||
const data = await res.json()
|
pending: isLoadingCategory,
|
||||||
categoryData.value = data
|
error: categoryError,
|
||||||
isLoadingCategory.value = false
|
} = await useAsyncData(
|
||||||
}
|
// 稳定 key:避免 hydration 期误判
|
||||||
|
'menu:categories',
|
||||||
|
() => $fetch(`${API_BASE_URL}/api/categories`),
|
||||||
|
{
|
||||||
|
server: true, // SSR 预取
|
||||||
|
default: () => [], // 初始默认值,减少空判断
|
||||||
|
// 5 分钟内复用缓存,避免路由往返重复请求
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
const fetchTagData = async () => {
|
const {
|
||||||
isLoadingTag.value = true
|
data: tagData,
|
||||||
const res = await fetch(`${API_BASE_URL}/api/tags?limit=10`)
|
pending: isLoadingTag,
|
||||||
const data = await res.json()
|
error: tagError,
|
||||||
tagData.value = data
|
} = await useAsyncData('menu:tags', () => $fetch(`${API_BASE_URL}/api/tags?limit=10`), {
|
||||||
isLoadingTag.value = false
|
server: true,
|
||||||
}
|
default: () => [],
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 其余逻辑保持不变 */
|
||||||
const iconClass = computed(() => {
|
const iconClass = computed(() => {
|
||||||
switch (themeState.mode) {
|
switch (themeState.mode) {
|
||||||
case ThemeMode.DARK:
|
case ThemeMode.DARK:
|
||||||
@@ -178,6 +205,15 @@ const unreadCount = computed(() => notificationState.unreadCount)
|
|||||||
const showUnreadCount = computed(() => (unreadCount.value > 99 ? '99+' : unreadCount.value))
|
const showUnreadCount = computed(() => (unreadCount.value > 99 ? '99+' : unreadCount.value))
|
||||||
const shouldShowStats = computed(() => authState.role === 'ADMIN')
|
const shouldShowStats = computed(() => authState.role === 'ADMIN')
|
||||||
|
|
||||||
|
const loadPoint = async () => {
|
||||||
|
if (authState.loggedIn) {
|
||||||
|
const user = await fetchCurrentUser()
|
||||||
|
myPoint.value = user ? user.point : null
|
||||||
|
} else {
|
||||||
|
myPoint.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const updateCount = async () => {
|
const updateCount = async () => {
|
||||||
if (authState.loggedIn) {
|
if (authState.loggedIn) {
|
||||||
await fetchUnreadCount()
|
await fetchUnreadCount()
|
||||||
@@ -187,14 +223,17 @@ const updateCount = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await updateCount()
|
await Promise.all([updateCount(), loadPoint()])
|
||||||
watch(() => authState.loggedIn, updateCount)
|
// 登录态变化时再拉一次未读数和积分;与 useAsyncData 无关
|
||||||
|
watch(
|
||||||
|
() => authState.loggedIn,
|
||||||
|
() => {
|
||||||
|
updateCount()
|
||||||
|
loadPoint()
|
||||||
|
},
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
const handleHomeClick = () => {
|
|
||||||
navigateTo('/', { replace: true })
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleItemClick = () => {
|
const handleItemClick = () => {
|
||||||
if (window.innerWidth <= 768) emit('item-click')
|
if (window.innerWidth <= 768) emit('item-click')
|
||||||
}
|
}
|
||||||
@@ -215,28 +254,35 @@ const gotoTag = (t) => {
|
|||||||
navigateTo({ path: '/', query: { tags: value } }, { replace: true })
|
navigateTo({ path: '/', query: { tags: value } }, { replace: true })
|
||||||
handleItemClick()
|
handleItemClick()
|
||||||
}
|
}
|
||||||
|
|
||||||
await Promise.all([fetchCategoryData(), fetchTagData()])
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.menu {
|
.menu {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: var(--header-height);
|
top: var(--header-height);
|
||||||
width: 200px;
|
width: 220px;
|
||||||
background-color: var(--menu-background-color);
|
background-color: var(--app-menu-background-color);
|
||||||
height: calc(100vh - 20px - var(--header-height));
|
height: calc(100vh - 20px - var(--header-height));
|
||||||
border-right: 1px solid var(--menu-border-color);
|
border-right: 1px solid var(--menu-border-color);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
padding: 10px;
|
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
scrollbar-width: none;
|
scrollbar-width: none;
|
||||||
|
backdrop-filter: var(--blur-10);
|
||||||
}
|
}
|
||||||
|
|
||||||
.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 {
|
.menu-item {
|
||||||
padding: 4px 10px;
|
padding: 4px 10px;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
@@ -275,6 +321,12 @@ await Promise.all([fetchCategoryData(), fetchTagData()])
|
|||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.point-count {
|
||||||
|
margin-left: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
.menu-item-icon {
|
.menu-item-icon {
|
||||||
margin-right: 10px;
|
margin-right: 10px;
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
@@ -282,10 +334,8 @@ await Promise.all([fetchCategoryData(), fetchTagData()])
|
|||||||
}
|
}
|
||||||
|
|
||||||
.menu-footer {
|
.menu-footer {
|
||||||
position: fixed;
|
position: relation;
|
||||||
height: 30px;
|
height: 30px;
|
||||||
bottom: 10px;
|
|
||||||
right: 10px;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
@@ -373,6 +423,10 @@ await Promise.all([fetchCategoryData(), fetchTagData()])
|
|||||||
background-color: var(--background-color-blur);
|
background-color: var(--background-color-blur);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.menu-content {
|
||||||
|
border-radius: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
.slide-enter-active,
|
.slide-enter-active,
|
||||||
.slide-leave-active {
|
.slide-leave-active {
|
||||||
transition:
|
transition:
|
||||||
|
|||||||
@@ -37,10 +37,10 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { useIsMobile } from '~/utils/screen'
|
import { ref, watch } from 'vue'
|
||||||
import Dropdown from '~/components/Dropdown.vue'
|
import Dropdown from '~/components/Dropdown.vue'
|
||||||
import { stripMarkdown } from '~/utils/markdown'
|
import { stripMarkdown } from '~/utils/markdown'
|
||||||
import { ref, watch } from 'vue'
|
import { useIsMobile } from '~/utils/screen'
|
||||||
const config = useRuntimeConfig()
|
const config = useRuntimeConfig()
|
||||||
const API_BASE_URL = config.public.apiBaseUrl
|
const API_BASE_URL = config.public.apiBaseUrl
|
||||||
|
|
||||||
@@ -110,6 +110,10 @@ watch(selected, (val) => {
|
|||||||
selected.value = null
|
selected.value = null
|
||||||
keyword.value = ''
|
keyword.value = ''
|
||||||
})
|
})
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
toggle,
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -131,7 +135,7 @@ watch(selected, (val) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.text-input {
|
.text-input {
|
||||||
background-color: var(--menu-background-color);
|
background-color: var(--app-menu-background-color);
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
border: none;
|
border: none;
|
||||||
outline: none;
|
outline: none;
|
||||||
|
|||||||
@@ -0,0 +1,547 @@
|
|||||||
|
<template>
|
||||||
|
<div class="tooltip-wrapper" ref="wrapperRef">
|
||||||
|
<!-- 触发器 -->
|
||||||
|
<div
|
||||||
|
class="tooltip-trigger"
|
||||||
|
:tabindex="focusable ? 0 : -1"
|
||||||
|
:aria-describedby="visible ? ariaId : undefined"
|
||||||
|
@mouseenter="onTriggerMouseEnter"
|
||||||
|
@mouseleave="onTriggerMouseLeave"
|
||||||
|
@click="onTriggerClick"
|
||||||
|
@focus="onTriggerFocus"
|
||||||
|
@blur="onTriggerBlur"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 提示内容(Teleport 到 body) -->
|
||||||
|
<Teleport to="body" v-if="mounted">
|
||||||
|
<Transition name="tooltip-fade">
|
||||||
|
<div
|
||||||
|
v-show="visible"
|
||||||
|
:id="ariaId"
|
||||||
|
ref="tooltipRef"
|
||||||
|
class="tooltip-content"
|
||||||
|
:class="[
|
||||||
|
`tooltip-${currentPlacement}`,
|
||||||
|
dark ? 'tooltip-dark' : 'tooltip-light',
|
||||||
|
props.trigger === 'hover' ? 'tooltip-noninteractive' : '',
|
||||||
|
]"
|
||||||
|
:style="tooltipInlineStyle"
|
||||||
|
role="tooltip"
|
||||||
|
>
|
||||||
|
<div class="tooltip-inner">
|
||||||
|
<slot name="content">
|
||||||
|
{{ content }}
|
||||||
|
</slot>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 箭头 -->
|
||||||
|
<div
|
||||||
|
class="tooltip-arrow"
|
||||||
|
:class="`tooltip-arrow-${currentPlacement}`"
|
||||||
|
:style="arrowStyle"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</Teleport>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
ref,
|
||||||
|
computed,
|
||||||
|
onMounted,
|
||||||
|
onBeforeUnmount,
|
||||||
|
nextTick,
|
||||||
|
watch,
|
||||||
|
defineProps,
|
||||||
|
defineEmits,
|
||||||
|
defineOptions,
|
||||||
|
useId,
|
||||||
|
} from 'vue'
|
||||||
|
|
||||||
|
defineOptions({ name: 'Tooltip' })
|
||||||
|
|
||||||
|
type Trigger = 'hover' | 'click' | 'focus' | 'manual'
|
||||||
|
type Placement = 'top' | 'bottom' | 'left' | 'right'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
content: { type: String, default: '' },
|
||||||
|
trigger: {
|
||||||
|
type: String as () => Trigger,
|
||||||
|
default: 'hover',
|
||||||
|
validator: (v: string) => ['hover', 'click', 'focus', 'manual'].includes(v),
|
||||||
|
},
|
||||||
|
placement: {
|
||||||
|
type: String as () => Placement,
|
||||||
|
default: 'top',
|
||||||
|
validator: (v: string) => ['top', 'bottom', 'left', 'right'].includes(v),
|
||||||
|
},
|
||||||
|
dark: { type: Boolean, default: false },
|
||||||
|
delay: { type: Number, default: 100 },
|
||||||
|
disabled: { type: Boolean, default: false },
|
||||||
|
focusable: { type: Boolean, default: true },
|
||||||
|
offset: { type: Number, default: 8 },
|
||||||
|
maxWidth: { type: [String, Number], default: '200px' },
|
||||||
|
/** 隐藏延时(毫秒),hover 离开后等待一点点以防抖 */
|
||||||
|
hideDelay: { type: Number, default: 80 },
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'show'): void
|
||||||
|
(e: 'hide'): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const wrapperRef = ref<HTMLElement | null>(null)
|
||||||
|
const tooltipRef = ref<HTMLElement | null>(null)
|
||||||
|
const visible = ref(false)
|
||||||
|
const currentPlacement = ref<Placement>(props.placement)
|
||||||
|
const ariaId = ref(`tooltip-${useId()}`)
|
||||||
|
const mounted = ref(false)
|
||||||
|
|
||||||
|
let showTimer: number | null = null
|
||||||
|
let hideTimer: number | null = null
|
||||||
|
let ro: ResizeObserver | null = null
|
||||||
|
let rafId: number | null = null
|
||||||
|
|
||||||
|
const maxWidthValue = computed(() => {
|
||||||
|
return typeof props.maxWidth === 'number' ? `${props.maxWidth}px` : props.maxWidth
|
||||||
|
})
|
||||||
|
|
||||||
|
const tooltipTransform = ref('translate3d(-9999px, -9999px, 0)')
|
||||||
|
|
||||||
|
const tooltipInlineStyle = computed(() => ({
|
||||||
|
position: 'fixed',
|
||||||
|
top: '0px',
|
||||||
|
left: '0px',
|
||||||
|
zIndex: 2000,
|
||||||
|
maxWidth: maxWidthValue.value,
|
||||||
|
transform: tooltipTransform.value,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const arrowStyle = ref<Record<string, string>>({})
|
||||||
|
|
||||||
|
const clearTimers = () => {
|
||||||
|
if (showTimer) {
|
||||||
|
window.clearTimeout(showTimer)
|
||||||
|
showTimer = null
|
||||||
|
}
|
||||||
|
if (hideTimer) {
|
||||||
|
window.clearTimeout(hideTimer)
|
||||||
|
hideTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const show = async () => {
|
||||||
|
if (props.disabled) return
|
||||||
|
clearTimers()
|
||||||
|
showTimer = window.setTimeout(async () => {
|
||||||
|
visible.value = true
|
||||||
|
emit('show')
|
||||||
|
await nextTick()
|
||||||
|
updatePosition()
|
||||||
|
}, props.delay)
|
||||||
|
}
|
||||||
|
|
||||||
|
const hide = () => {
|
||||||
|
clearTimers()
|
||||||
|
hideTimer = window.setTimeout(() => {
|
||||||
|
visible.value = false
|
||||||
|
emit('hide')
|
||||||
|
}, props.hideDelay)
|
||||||
|
}
|
||||||
|
|
||||||
|
const showImmediately = async () => {
|
||||||
|
if (props.disabled) return
|
||||||
|
clearTimers()
|
||||||
|
visible.value = true
|
||||||
|
emit('show')
|
||||||
|
await nextTick()
|
||||||
|
updatePosition()
|
||||||
|
}
|
||||||
|
const hideImmediately = () => {
|
||||||
|
clearTimers()
|
||||||
|
visible.value = false
|
||||||
|
emit('hide')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 触发器事件
|
||||||
|
const onTriggerMouseEnter = () => {
|
||||||
|
if (props.trigger === 'hover') show()
|
||||||
|
}
|
||||||
|
const onTriggerMouseLeave = () => {
|
||||||
|
// 关键修改:hover 模式下,离开触发区即开始隐藏计时,不再保持可交互
|
||||||
|
if (props.trigger === 'hover') hide()
|
||||||
|
}
|
||||||
|
const onTriggerClick = () => {
|
||||||
|
if (props.trigger !== 'click') return
|
||||||
|
visible.value ? hideImmediately() : showImmediately()
|
||||||
|
}
|
||||||
|
const onTriggerFocus = () => {
|
||||||
|
if (props.trigger === 'focus') showImmediately()
|
||||||
|
}
|
||||||
|
const onTriggerBlur = () => {
|
||||||
|
if (props.trigger === 'focus') hideImmediately()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 点击外部关闭(只对 click 模式)
|
||||||
|
const onClickOutside = (e: MouseEvent) => {
|
||||||
|
if (props.trigger !== 'click') return
|
||||||
|
const w = wrapperRef.value
|
||||||
|
const t = tooltipRef.value
|
||||||
|
const target = e.target as Node
|
||||||
|
if (w && !w.contains(target) && t && !t.contains(target)) {
|
||||||
|
hideImmediately()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 定位算法
|
||||||
|
function clamp(n: number, min: number, max: number) {
|
||||||
|
return Math.max(min, Math.min(max, n))
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeBasePosition(
|
||||||
|
placement: Placement,
|
||||||
|
triggerRect: DOMRect,
|
||||||
|
tooltipRect: DOMRect,
|
||||||
|
offset: number,
|
||||||
|
) {
|
||||||
|
const centerX = triggerRect.left + triggerRect.width / 2
|
||||||
|
const centerY = triggerRect.top + triggerRect.height / 2
|
||||||
|
|
||||||
|
switch (placement) {
|
||||||
|
case 'top':
|
||||||
|
return {
|
||||||
|
top: triggerRect.top - tooltipRect.height - offset,
|
||||||
|
left: centerX - tooltipRect.width / 2,
|
||||||
|
}
|
||||||
|
case 'bottom':
|
||||||
|
return {
|
||||||
|
top: triggerRect.bottom + offset,
|
||||||
|
left: centerX - tooltipRect.width / 2,
|
||||||
|
}
|
||||||
|
case 'left':
|
||||||
|
return {
|
||||||
|
top: centerY - tooltipRect.height / 2,
|
||||||
|
left: triggerRect.left - tooltipRect.width - offset,
|
||||||
|
}
|
||||||
|
case 'right':
|
||||||
|
return {
|
||||||
|
top: centerY - tooltipRect.height / 2,
|
||||||
|
left: triggerRect.right + offset,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function positionWithSmartFlip(
|
||||||
|
preferred: Placement,
|
||||||
|
triggerRect: DOMRect,
|
||||||
|
tooltipRect: DOMRect,
|
||||||
|
offset: number,
|
||||||
|
) {
|
||||||
|
const padding = 8
|
||||||
|
const vw = window.innerWidth
|
||||||
|
const vh = window.innerHeight
|
||||||
|
|
||||||
|
let placement: Placement = preferred
|
||||||
|
let { top, left } = computeBasePosition(placement, triggerRect, tooltipRect, offset)!
|
||||||
|
|
||||||
|
const outTop = top < padding
|
||||||
|
const outBottom = top + tooltipRect.height > vh - padding
|
||||||
|
const outLeft = left < padding
|
||||||
|
const outRight = left + tooltipRect.width > vw - padding
|
||||||
|
|
||||||
|
if (
|
||||||
|
placement === 'top' &&
|
||||||
|
outTop &&
|
||||||
|
triggerRect.bottom + offset + tooltipRect.height <= vh - padding
|
||||||
|
) {
|
||||||
|
placement = 'bottom'
|
||||||
|
;({ top, left } = computeBasePosition(placement, triggerRect, tooltipRect, offset)!)
|
||||||
|
} else if (
|
||||||
|
placement === 'bottom' &&
|
||||||
|
outBottom &&
|
||||||
|
triggerRect.top - offset - tooltipRect.height >= padding
|
||||||
|
) {
|
||||||
|
placement = 'top'
|
||||||
|
;({ top, left } = computeBasePosition(placement, triggerRect, tooltipRect, offset)!)
|
||||||
|
} else if (
|
||||||
|
placement === 'left' &&
|
||||||
|
outLeft &&
|
||||||
|
triggerRect.right + offset + tooltipRect.width <= vw - padding
|
||||||
|
) {
|
||||||
|
placement = 'right'
|
||||||
|
;({ top, left } = computeBasePosition(placement, triggerRect, tooltipRect, offset)!)
|
||||||
|
} else if (
|
||||||
|
placement === 'right' &&
|
||||||
|
outRight &&
|
||||||
|
triggerRect.left - offset - tooltipRect.width >= padding
|
||||||
|
) {
|
||||||
|
placement = 'left'
|
||||||
|
;({ top, left } = computeBasePosition(placement, triggerRect, tooltipRect, offset)!)
|
||||||
|
}
|
||||||
|
|
||||||
|
top = clamp(top, padding, vh - tooltipRect.height - padding)
|
||||||
|
left = clamp(left, padding, vw - tooltipRect.width - padding)
|
||||||
|
|
||||||
|
const triggerCenterX = triggerRect.left + triggerRect.width / 2
|
||||||
|
const triggerCenterY = triggerRect.top + triggerRect.height / 2
|
||||||
|
const arrowLeft = clamp(triggerCenterX - left, 10, tooltipRect.width - 10)
|
||||||
|
const arrowTop = clamp(triggerCenterY - top, 10, tooltipRect.height - 10)
|
||||||
|
|
||||||
|
return { placement, top, left, arrowLeft, arrowTop }
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatePosition = () => {
|
||||||
|
if (!wrapperRef.value || !tooltipRef.value || !visible.value) return
|
||||||
|
if (rafId) cancelAnimationFrame(rafId)
|
||||||
|
rafId = requestAnimationFrame(() => {
|
||||||
|
const triggerEl = wrapperRef.value!.querySelector('.tooltip-trigger') as HTMLElement | null
|
||||||
|
const tooltipEl = tooltipRef.value!
|
||||||
|
if (!triggerEl) return
|
||||||
|
|
||||||
|
const triggerRect = triggerEl.getBoundingClientRect()
|
||||||
|
const tooltipRect = tooltipEl.getBoundingClientRect()
|
||||||
|
const { placement, top, left, arrowLeft, arrowTop } = positionWithSmartFlip(
|
||||||
|
props.placement,
|
||||||
|
triggerRect,
|
||||||
|
tooltipRect,
|
||||||
|
props.offset,
|
||||||
|
)
|
||||||
|
|
||||||
|
currentPlacement.value = placement
|
||||||
|
tooltipTransform.value = `translate3d(${Math.round(left)}px, ${Math.round(top)}px, 0)`
|
||||||
|
if (placement === 'top' || placement === 'bottom') {
|
||||||
|
arrowStyle.value = { '--arrow-left': `${Math.round(arrowLeft)}px` } as any
|
||||||
|
} else {
|
||||||
|
arrowStyle.value = { '--arrow-top': `${Math.round(arrowTop)}px` } as any
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const onEnvChanged = () => {
|
||||||
|
if (visible.value) updatePosition()
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.disabled,
|
||||||
|
(v) => {
|
||||||
|
if (v && visible.value) hideImmediately()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
watch(
|
||||||
|
() => props.placement,
|
||||||
|
() => {
|
||||||
|
if (visible.value) nextTick(updatePosition)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
watch(visible, (v) => {
|
||||||
|
if (!mounted.value) return
|
||||||
|
if (v) {
|
||||||
|
if ('ResizeObserver' in window && !ro) {
|
||||||
|
ro = new ResizeObserver(() => updatePosition())
|
||||||
|
if (tooltipRef.value) ro.observe(tooltipRef.value)
|
||||||
|
const triggerEl = wrapperRef.value?.querySelector('.tooltip-trigger') as HTMLElement | null
|
||||||
|
if (triggerEl) ro.observe(triggerEl)
|
||||||
|
}
|
||||||
|
updatePosition()
|
||||||
|
} else {
|
||||||
|
if (ro) {
|
||||||
|
ro.disconnect()
|
||||||
|
ro = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
mounted.value = true
|
||||||
|
window.addEventListener('resize', onEnvChanged, { passive: true })
|
||||||
|
window.addEventListener('scroll', onEnvChanged, { passive: true, capture: true })
|
||||||
|
document.addEventListener('click', onClickOutside, true)
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
clearTimers()
|
||||||
|
if (rafId) cancelAnimationFrame(rafId)
|
||||||
|
if (ro) {
|
||||||
|
ro.disconnect()
|
||||||
|
ro = null
|
||||||
|
}
|
||||||
|
document.removeEventListener('click', onClickOutside, true)
|
||||||
|
window.removeEventListener('resize', onEnvChanged)
|
||||||
|
window.removeEventListener('scroll', onEnvChanged, true)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 暴露给父组件(manual 可用)
|
||||||
|
defineExpose({ show: showImmediately, hide: hideImmediately, updatePosition })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.tooltip-wrapper {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
.tooltip-trigger {
|
||||||
|
display: inline-block;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.tooltip-trigger:focus-visible {
|
||||||
|
outline: 2px solid var(--primary-color);
|
||||||
|
outline-offset: 2px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-content {
|
||||||
|
will-change: transform;
|
||||||
|
pointer-events: auto; /* 默认允许交互(click/focus 模式) */
|
||||||
|
}
|
||||||
|
.tooltip-noninteractive {
|
||||||
|
/* hover 模式下禁用指针事件,避免移入浮层导致保持显示 */
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-inner {
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.4;
|
||||||
|
word-wrap: break-word;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
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-color: var(--normal-border-color);
|
||||||
|
}
|
||||||
|
.tooltip-dark .tooltip-inner {
|
||||||
|
background-color: rgba(0, 0, 0, 0.9);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 箭头(用 CSS 变量控制偏移) */
|
||||||
|
.tooltip-arrow {
|
||||||
|
position: absolute;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
border-style: solid;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 顶部 */
|
||||||
|
.tooltip-top .tooltip-arrow-top {
|
||||||
|
bottom: -6px;
|
||||||
|
left: var(--arrow-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: var(--arrow-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: var(--arrow-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: var(--arrow-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:
|
||||||
|
opacity 0.18s ease,
|
||||||
|
transform 0.18s ease;
|
||||||
|
}
|
||||||
|
.tooltip-fade-enter-from,
|
||||||
|
.tooltip-fade-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translate3d(0, 4px, 0) scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 响应式微调 */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.tooltip-inner {
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
max-width: 250px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1 +1,5 @@
|
|||||||
export { toast } from './composables/useToast'
|
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
|
||||||
|
|||||||
@@ -12,9 +12,10 @@ export default defineNuxtConfig({
|
|||||||
twitterClientId: process.env.NUXT_PUBLIC_TWITTER_CLIENT_ID || '',
|
twitterClientId: process.env.NUXT_PUBLIC_TWITTER_CLIENT_ID || '',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
// Ensure Vditor styles load before our overrides in global.css
|
// 确保 Vditor 样式在 global.css 覆盖前加载
|
||||||
css: ['vditor/dist/index.css', '~/assets/global.css'],
|
css: ['vditor/dist/index.css', '~/assets/fonts.css', '~/assets/global.css'],
|
||||||
app: {
|
app: {
|
||||||
|
pageTransition: { name: 'page', mode: 'out-in' },
|
||||||
head: {
|
head: {
|
||||||
script: [
|
script: [
|
||||||
{
|
{
|
||||||
@@ -26,7 +27,31 @@ export default defineNuxtConfig({
|
|||||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||||
const theme = mode === 'dark' || mode === 'light' ? mode : (prefersDark ? 'dark' : 'light');
|
const theme = mode === 'dark' || mode === 'light' ? mode : (prefersDark ? 'dark' : 'light');
|
||||||
document.documentElement.dataset.theme = theme;
|
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: {
|
vue: {
|
||||||
compilerOptions: {
|
compilerOptions: {
|
||||||
isCustomElement: (tag) => ['l-hatch', 'l-hatch-spinner'].includes(tag),
|
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'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
Generated
+3232
-1718
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,60 @@
|
|||||||
|
<template>
|
||||||
|
<div class="point-mall-page">
|
||||||
|
<p v-if="authState.loggedIn && point !== null">我的积分:{{ point }}</p>
|
||||||
|
<p v-else>请先登录以查看积分</p>
|
||||||
|
|
||||||
|
<section class="rules">
|
||||||
|
<h2>积分规则</h2>
|
||||||
|
<ul>
|
||||||
|
<li v-for="(rule, idx) in pointRules" :key="idx">{{ rule }}</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="goods">
|
||||||
|
<h2>积分兑换商品</h2>
|
||||||
|
<ul>
|
||||||
|
<li v-for="(good, idx) in goods" :key="idx">{{ good.name }} - {{ good.cost }} 积分</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { onMounted, ref } from 'vue'
|
||||||
|
import { authState, fetchCurrentUser } from '~/utils/auth'
|
||||||
|
|
||||||
|
const point = ref(null)
|
||||||
|
|
||||||
|
const pointRules = [
|
||||||
|
'发帖:每天前两次,每次 30 积分',
|
||||||
|
'评论:每天前四条评论可获 10 积分,你的帖子被评论也可获 10 积分',
|
||||||
|
'帖子被点赞:每次 10 积分',
|
||||||
|
'评论被点赞:每次 10 积分',
|
||||||
|
]
|
||||||
|
|
||||||
|
const goods = [
|
||||||
|
{ name: 'GPT Plus for 1 month', cost: 20000 },
|
||||||
|
{ name: '奶茶', cost: 5000 },
|
||||||
|
]
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
if (authState.loggedIn) {
|
||||||
|
const user = await fetchCurrentUser()
|
||||||
|
point.value = user ? user.point : null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.point-mall-page {
|
||||||
|
padding: 20px;
|
||||||
|
max-width: var(--page-max-width);
|
||||||
|
background-color: var(--background-color);
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rules,
|
||||||
|
.goods {
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
<div class="forgot-page">
|
<div class="forgot-page">
|
||||||
<div class="forgot-content">
|
<div class="forgot-content">
|
||||||
<div class="forgot-title">找回密码</div>
|
<div class="forgot-title">找回密码</div>
|
||||||
|
|
||||||
<div v-if="step === 0" class="step-content">
|
<div v-if="step === 0" class="step-content">
|
||||||
<BaseInput icon="fas fa-envelope" v-model="email" placeholder="邮箱" />
|
<BaseInput icon="fas fa-envelope" v-model="email" placeholder="邮箱" />
|
||||||
<div v-if="emailError" class="error-message">{{ emailError }}</div>
|
<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" @click="resetPassword" v-if="!isResetting">重置密码</div>
|
||||||
<div class="primary-button disabled" v-else>提交中...</div>
|
<div class="primary-button disabled" v-else>提交中...</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="hint-message">
|
||||||
|
<i class="fas fa-info-circle"></i>
|
||||||
|
使用 Google 注册的用户可使用对应的邮箱进行找回密码
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -26,6 +31,8 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { toast } from '~/main'
|
import { toast } from '~/main'
|
||||||
import BaseInput from '~/components/BaseInput.vue'
|
import BaseInput from '~/components/BaseInput.vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
|
||||||
const config = useRuntimeConfig()
|
const config = useRuntimeConfig()
|
||||||
const API_BASE_URL = config.public.apiBaseUrl
|
const API_BASE_URL = config.public.apiBaseUrl
|
||||||
|
|
||||||
@@ -39,6 +46,7 @@ const passwordError = ref('')
|
|||||||
const isSending = ref(false)
|
const isSending = ref(false)
|
||||||
const isVerifying = ref(false)
|
const isVerifying = ref(false)
|
||||||
const isResetting = ref(false)
|
const isResetting = ref(false)
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (route.query.email) {
|
if (route.query.email) {
|
||||||
@@ -137,6 +145,21 @@ const resetPassword = async () => {
|
|||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
font-weight: bold;
|
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 {
|
.step-content {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="home-page">
|
<div class="home-page">
|
||||||
<div v-if="!isMobile" class="search-container">
|
<div v-if="!isMobile" class="search-container">
|
||||||
<div class="search-title">一切可能,从此刻启航</div>
|
<div class="search-title">一切可能,从此刻启航,在此遇见灵感与共鸣</div>
|
||||||
<div class="search-subtitle">
|
|
||||||
愿你在此遇见灵感与共鸣。若有疑惑,欢迎发问,亦可在知识的海洋中搜寻答案。
|
|
||||||
</div>
|
|
||||||
<SearchDropdown />
|
<SearchDropdown />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -116,7 +113,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, watch, watchEffect, computed, onMounted, onBeforeUnmount } from 'vue'
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
import ArticleCategory from '~/components/ArticleCategory.vue'
|
import ArticleCategory from '~/components/ArticleCategory.vue'
|
||||||
import ArticleTags from '~/components/ArticleTags.vue'
|
import ArticleTags from '~/components/ArticleTags.vue'
|
||||||
import CategorySelect from '~/components/CategorySelect.vue'
|
import CategorySelect from '~/components/CategorySelect.vue'
|
||||||
@@ -150,9 +147,17 @@ const categoryOptions = ref([])
|
|||||||
const isLoadingMore = ref(false)
|
const isLoadingMore = ref(false)
|
||||||
|
|
||||||
const topics = ref(['最新回复', '最新', '排行榜' /*, '热门', '类别'*/])
|
const topics = ref(['最新回复', '最新', '排行榜' /*, '热门', '类别'*/])
|
||||||
|
const selectedTopicCookie = useCookie('homeTab')
|
||||||
const selectedTopic = ref(
|
const selectedTopic = ref(
|
||||||
route.query.view === 'ranking' ? '排行榜' : route.query.view === 'latest' ? '最新' : '最新回复',
|
selectedTopicCookie.value
|
||||||
|
? selectedTopicCookie.value
|
||||||
|
: route.query.view === 'ranking'
|
||||||
|
? '排行榜'
|
||||||
|
: route.query.view === 'latest'
|
||||||
|
? '最新'
|
||||||
|
: '最新回复',
|
||||||
)
|
)
|
||||||
|
if (!selectedTopicCookie.value) selectedTopicCookie.value = selectedTopic.value
|
||||||
const articles = ref([])
|
const articles = ref([])
|
||||||
const page = ref(0)
|
const page = ref(0)
|
||||||
const pageSize = 10
|
const pageSize = 10
|
||||||
@@ -178,6 +183,11 @@ onMounted(() => {
|
|||||||
const { category, tags } = route.query
|
const { category, tags } = route.query
|
||||||
if (category) selectedCategorySet(category)
|
if (category) selectedCategorySet(category)
|
||||||
if (tags) selectedTagsSet(tags)
|
if (tags) selectedTagsSet(tags)
|
||||||
|
|
||||||
|
const saved = localStorage.getItem('homeTab')
|
||||||
|
if (saved) {
|
||||||
|
selectedTopic.value = saved
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
/** 路由变更时同步筛选 **/
|
/** 路由变更时同步筛选 **/
|
||||||
@@ -195,7 +205,7 @@ watch(
|
|||||||
const loadOptions = async () => {
|
const loadOptions = async () => {
|
||||||
if (selectedCategory.value && !isNaN(selectedCategory.value)) {
|
if (selectedCategory.value && !isNaN(selectedCategory.value)) {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API_BASE_URL}/api/categories/${selectedCategory.value}`)
|
const res = await fetch(`${API_BASE_URL}/api/categories/`)
|
||||||
if (res.ok) categoryOptions.value = [await res.json()]
|
if (res.ok) categoryOptions.value = [await res.json()]
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
@@ -265,7 +275,7 @@ const {
|
|||||||
time: TimeManager.format(
|
time: TimeManager.format(
|
||||||
selectedTopic.value === '最新回复' ? p.lastReplyAt || p.createdAt : p.createdAt,
|
selectedTopic.value === '最新回复' ? p.lastReplyAt || p.createdAt : p.createdAt,
|
||||||
),
|
),
|
||||||
pinned: !!p.pinnedAt,
|
pinned: Boolean(p.pinned ?? p.pinnedAt ?? p.pinned_at),
|
||||||
type: p.type,
|
type: p.type,
|
||||||
}))
|
}))
|
||||||
},
|
},
|
||||||
@@ -308,7 +318,7 @@ const fetchNextPage = async () => {
|
|||||||
time: TimeManager.format(
|
time: TimeManager.format(
|
||||||
selectedTopic.value === '最新回复' ? p.lastReplyAt || p.createdAt : p.createdAt,
|
selectedTopic.value === '最新回复' ? p.lastReplyAt || p.createdAt : p.createdAt,
|
||||||
),
|
),
|
||||||
pinned: !!p.pinnedAt,
|
pinned: Boolean(p.pinned ?? p.pinnedAt ?? p.pinned_at),
|
||||||
type: p.type,
|
type: p.type,
|
||||||
}))
|
}))
|
||||||
articles.value.push(...mapped)
|
articles.value.push(...mapped)
|
||||||
@@ -343,9 +353,13 @@ watch(
|
|||||||
watch([selectedCategory, selectedTags], () => {
|
watch([selectedCategory, selectedTags], () => {
|
||||||
loadOptions()
|
loadOptions()
|
||||||
})
|
})
|
||||||
watch(selectedTopic, () => {
|
watch(selectedTopic, (val) => {
|
||||||
// 仅当需要额外选项时加载
|
// 仅当需要额外选项时加载
|
||||||
loadOptions()
|
loadOptions()
|
||||||
|
selectedTopicCookie.value = val
|
||||||
|
if (process.client) {
|
||||||
|
localStorage.setItem('homeTab', val)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
/** 选项首屏加载:服务端执行一次;客户端兜底 **/
|
/** 选项首屏加载:服务端执行一次;客户端兜底 **/
|
||||||
@@ -354,6 +368,8 @@ if (import.meta.server) {
|
|||||||
}
|
}
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (categoryOptions.value.length === 0 && tagOptions.value.length === 0) loadOptions()
|
if (categoryOptions.value.length === 0 && tagOptions.value.length === 0) loadOptions()
|
||||||
|
|
||||||
|
window.addEventListener('refresh-home', refreshFirst)
|
||||||
})
|
})
|
||||||
|
|
||||||
/** 其他工具函数 **/
|
/** 其他工具函数 **/
|
||||||
@@ -371,8 +387,8 @@ const sanitizeDescription = (text) => stripMarkdown(text)
|
|||||||
}
|
}
|
||||||
|
|
||||||
.search-container {
|
.search-container {
|
||||||
margin-top: 100px;
|
margin-top: 32px;
|
||||||
padding: 20px;
|
padding: 20px 20px 32px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -384,10 +400,6 @@ const sanitizeDescription = (text) => stripMarkdown(text)
|
|||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-subtitle {
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading-container {
|
.loading-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -422,6 +434,7 @@ const sanitizeDescription = (text) => stripMarkdown(text)
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 10px 0;
|
padding: 10px 0;
|
||||||
|
backdrop-filter: var(--blur-10);
|
||||||
}
|
}
|
||||||
|
|
||||||
.topic-item-container {
|
.topic-item-container {
|
||||||
@@ -541,6 +554,7 @@ const sanitizeDescription = (text) => stripMarkdown(text)
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: gray;
|
color: gray;
|
||||||
display: -webkit-box;
|
display: -webkit-box;
|
||||||
|
line-clamp: 3;
|
||||||
-webkit-line-clamp: 3;
|
-webkit-line-clamp: 3;
|
||||||
-webkit-box-orient: vertical;
|
-webkit-box-orient: vertical;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|||||||
@@ -27,6 +27,10 @@
|
|||||||
>找回密码</a
|
>找回密码</a
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="hint-message">
|
||||||
|
<i class="fas fa-info-circle"></i>
|
||||||
|
使用右侧第三方OAuth注册/登录的用户可使用对应的邮箱进行重设密码
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -259,6 +263,11 @@ const loginWithTwitter = () => {
|
|||||||
color: var(--primary-color);
|
color: var(--primary-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hint-message {
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.login-page {
|
.login-page {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
+16
-236
@@ -505,21 +505,26 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted, computed } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
import BaseTimeline from '~/components/BaseTimeline.vue'
|
|
||||||
import BasePlaceholder from '~/components/BasePlaceholder.vue'
|
import BasePlaceholder from '~/components/BasePlaceholder.vue'
|
||||||
|
import BaseTimeline from '~/components/BaseTimeline.vue'
|
||||||
import NotificationContainer from '~/components/NotificationContainer.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 { toast } from '~/main'
|
||||||
|
import { authState, getToken } from '~/utils/auth'
|
||||||
import { stripMarkdownLength } from '~/utils/markdown'
|
import { stripMarkdownLength } from '~/utils/markdown'
|
||||||
|
import {
|
||||||
|
fetchNotifications,
|
||||||
|
fetchUnreadCount,
|
||||||
|
isLoadingMessage,
|
||||||
|
markRead,
|
||||||
|
notifications,
|
||||||
|
markAllRead,
|
||||||
|
} from '~/utils/notification'
|
||||||
import TimeManager from '~/utils/time'
|
import TimeManager from '~/utils/time'
|
||||||
import { reactionEmojiMap } from '~/utils/reactions'
|
|
||||||
const config = useRuntimeConfig()
|
const config = useRuntimeConfig()
|
||||||
const API_BASE_URL = config.public.apiBaseUrl
|
const API_BASE_URL = config.public.apiBaseUrl
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const notifications = ref([])
|
|
||||||
const isLoadingMessage = ref(false)
|
|
||||||
const selectedTab = ref(
|
const selectedTab = ref(
|
||||||
['all', 'unread', 'control'].includes(route.query.tab) ? route.query.tab : 'unread',
|
['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),
|
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 () => {
|
const fetchPrefs = async () => {
|
||||||
notificationPrefs.value = await fetchNotificationPreferences()
|
notificationPrefs.value = await fetchNotificationPreferences()
|
||||||
}
|
}
|
||||||
@@ -842,7 +619,7 @@ const formatType = (t) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onActivated(() => {
|
||||||
fetchNotifications()
|
fetchNotifications()
|
||||||
fetchPrefs()
|
fetchPrefs()
|
||||||
})
|
})
|
||||||
@@ -859,6 +636,8 @@ onMounted(() => {
|
|||||||
.message-page {
|
.message-page {
|
||||||
background-color: var(--background-color);
|
background-color: var(--background-color);
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
|
height: calc(100vh - var(--header-height));
|
||||||
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-page-header {
|
.message-page-header {
|
||||||
@@ -870,6 +649,7 @@ onMounted(() => {
|
|||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
backdrop-filter: var(--blur-10);
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-page-header-right {
|
.message-page-header-right {
|
||||||
|
|||||||
@@ -77,7 +77,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup>
|
||||||
import 'flatpickr/dist/flatpickr.css'
|
import 'flatpickr/dist/flatpickr.css'
|
||||||
import { computed, onMounted, ref, watch } from 'vue'
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
import FlatPickr from 'vue-flatpickr-component'
|
import FlatPickr from 'vue-flatpickr-component'
|
||||||
|
|||||||
@@ -232,7 +232,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<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 VueEasyLightbox from 'vue-easy-lightbox'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import CommentItem from '~/components/CommentItem.vue'
|
import CommentItem from '~/components/CommentItem.vue'
|
||||||
@@ -268,7 +268,6 @@ const postReactions = ref([])
|
|||||||
const comments = ref([])
|
const comments = ref([])
|
||||||
const status = ref('PUBLISHED')
|
const status = ref('PUBLISHED')
|
||||||
const pinnedAt = ref(null)
|
const pinnedAt = ref(null)
|
||||||
const isWaitingFetchingPost = ref(false)
|
|
||||||
const isWaitingPostingComment = ref(false)
|
const isWaitingPostingComment = ref(false)
|
||||||
const postTime = ref('')
|
const postTime = ref('')
|
||||||
const postItems = ref([])
|
const postItems = ref([])
|
||||||
@@ -392,7 +391,7 @@ const mapComment = (c, parentUserName = '', level = 0) => ({
|
|||||||
avatar: c.author.avatar,
|
avatar: c.author.avatar,
|
||||||
text: c.content,
|
text: c.content,
|
||||||
reactions: c.reactions || [],
|
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)),
|
reply: (c.replies || []).map((r) => mapComment(r, c.author.username, level + 1)),
|
||||||
openReplies: level === 0,
|
openReplies: level === 0,
|
||||||
src: c.author.avatar,
|
src: c.author.avatar,
|
||||||
@@ -455,38 +454,41 @@ const onCommentDeleted = (id) => {
|
|||||||
fetchComments()
|
fetchComments()
|
||||||
}
|
}
|
||||||
|
|
||||||
const fetchPost = async () => {
|
const {
|
||||||
try {
|
data: postData,
|
||||||
isWaitingFetchingPost.value = true
|
pending: pendingPost,
|
||||||
const token = getToken()
|
error: postError,
|
||||||
const res = await fetch(`${API_BASE_URL}/api/posts/${postId}`, {
|
refresh: refreshPost,
|
||||||
headers: { Authorization: token ? `Bearer ${token}` : '' },
|
} = await useAsyncData(`post-${postId}`, () => $fetch(`${API_BASE_URL}/api/posts/${postId}`), {
|
||||||
})
|
server: true,
|
||||||
isWaitingFetchingPost.value = false
|
lazy: false,
|
||||||
if (!res.ok) {
|
})
|
||||||
if (res.status === 404 && process.client) {
|
|
||||||
router.replace('/404')
|
// 用 pendingPost 驱动现有 UI(替代 isWaitingFetchingPost 手控)
|
||||||
}
|
const isWaitingFetchingPost = computed(() => pendingPost.value)
|
||||||
return
|
|
||||||
}
|
// 同步到现有的响应式字段
|
||||||
const data = await res.json()
|
watchEffect(() => {
|
||||||
postContent.value = data.content
|
const data = postData.value
|
||||||
author.value = data.author
|
if (!data) return
|
||||||
title.value = data.title
|
postContent.value = data.content
|
||||||
category.value = data.category
|
author.value = data.author
|
||||||
tags.value = data.tags || []
|
title.value = data.title
|
||||||
postReactions.value = data.reactions || []
|
category.value = data.category
|
||||||
subscribed.value = !!data.subscribed
|
tags.value = data.tags || []
|
||||||
status.value = data.status
|
postReactions.value = data.reactions || []
|
||||||
pinnedAt.value = data.pinnedAt
|
subscribed.value = !!data.subscribed
|
||||||
postTime.value = TimeManager.format(data.createdAt)
|
status.value = data.status
|
||||||
lottery.value = data.lottery || null
|
pinnedAt.value = data.pinnedAt
|
||||||
if (lottery.value && lottery.value.endTime) startCountdown()
|
postTime.value = TimeManager.format(data.createdAt)
|
||||||
await nextTick()
|
lottery.value = data.lottery || null
|
||||||
} catch (e) {
|
if (lottery.value && lottery.value.endTime) startCountdown()
|
||||||
console.error(e)
|
})
|
||||||
}
|
|
||||||
}
|
// 404 客户端跳转
|
||||||
|
// if (postError.value?.statusCode === 404 && process.client) {
|
||||||
|
// router.replace('/404')
|
||||||
|
// }
|
||||||
|
|
||||||
const totalPosts = computed(() => comments.value.length + 1)
|
const totalPosts = computed(() => comments.value.length + 1)
|
||||||
const lastReplyTime = computed(() =>
|
const lastReplyTime = computed(() =>
|
||||||
@@ -607,6 +609,7 @@ const approvePost = async () => {
|
|||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
status.value = 'PUBLISHED'
|
status.value = 'PUBLISHED'
|
||||||
toast.success('已通过审核')
|
toast.success('已通过审核')
|
||||||
|
await refreshPost()
|
||||||
} else {
|
} else {
|
||||||
toast.error('操作失败')
|
toast.error('操作失败')
|
||||||
}
|
}
|
||||||
@@ -620,8 +623,8 @@ const pinPost = async () => {
|
|||||||
headers: { Authorization: `Bearer ${token}` },
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
})
|
})
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
pinnedAt.value = new Date().toISOString()
|
|
||||||
toast.success('已置顶')
|
toast.success('已置顶')
|
||||||
|
await refreshPost()
|
||||||
} else {
|
} else {
|
||||||
toast.error('操作失败')
|
toast.error('操作失败')
|
||||||
}
|
}
|
||||||
@@ -635,8 +638,8 @@ const unpinPost = async () => {
|
|||||||
headers: { Authorization: `Bearer ${token}` },
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
})
|
})
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
pinnedAt.value = null
|
|
||||||
toast.success('已取消置顶')
|
toast.success('已取消置顶')
|
||||||
|
await refreshPost()
|
||||||
} else {
|
} else {
|
||||||
toast.error('操作失败')
|
toast.error('操作失败')
|
||||||
}
|
}
|
||||||
@@ -674,6 +677,7 @@ const rejectPost = async () => {
|
|||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
status.value = 'REJECTED'
|
status.value = 'REJECTED'
|
||||||
toast.success('已驳回')
|
toast.success('已驳回')
|
||||||
|
await refreshPost()
|
||||||
} else {
|
} else {
|
||||||
toast.error('操作失败')
|
toast.error('操作失败')
|
||||||
}
|
}
|
||||||
@@ -709,7 +713,7 @@ const joinLottery = async () => {
|
|||||||
})
|
})
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
toast.success('已参与抽奖')
|
toast.success('已参与抽奖')
|
||||||
await fetchPost()
|
await refreshPost()
|
||||||
} else {
|
} else {
|
||||||
toast.error('操作失败')
|
toast.error('操作失败')
|
||||||
}
|
}
|
||||||
@@ -780,9 +784,8 @@ onMounted(async () => {
|
|||||||
window.addEventListener('scroll', updateCurrentIndex)
|
window.addEventListener('scroll', updateCurrentIndex)
|
||||||
jumpToHashComment()
|
jumpToHashComment()
|
||||||
})
|
})
|
||||||
|
|
||||||
await fetchPost()
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.post-page-container {
|
.post-page-container {
|
||||||
background-color: var(--background-color);
|
background-color: var(--background-color);
|
||||||
@@ -864,6 +867,7 @@ await fetchPost()
|
|||||||
direction: ltr;
|
direction: ltr;
|
||||||
height: 300px;
|
height: 300px;
|
||||||
width: 2px;
|
width: 2px;
|
||||||
|
appearance: none;
|
||||||
-webkit-appearance: none;
|
-webkit-appearance: none;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,13 @@
|
|||||||
<BaseInput v-model="introduction" textarea rows="3" placeholder="说些什么..." />
|
<BaseInput v-model="introduction" textarea rows="3" placeholder="说些什么..." />
|
||||||
<div class="setting-description">自我介绍会出现在你的个人主页,可以简要介绍自己</div>
|
<div class="setting-description">自我介绍会出现在你的个人主页,可以简要介绍自己</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-row switch-row">
|
||||||
|
<div class="setting-title">毛玻璃效果</div>
|
||||||
|
<label class="switch">
|
||||||
|
<input type="checkbox" v-model="frosted" />
|
||||||
|
<span class="slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="role === 'ADMIN'" class="admin-section">
|
<div v-if="role === 'ADMIN'" class="admin-section">
|
||||||
<h3>管理员设置</h3>
|
<h3>管理员设置</h3>
|
||||||
@@ -65,12 +72,13 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted, watch } from 'vue'
|
||||||
import AvatarCropper from '~/components/AvatarCropper.vue'
|
import AvatarCropper from '~/components/AvatarCropper.vue'
|
||||||
import BaseInput from '~/components/BaseInput.vue'
|
import BaseInput from '~/components/BaseInput.vue'
|
||||||
import Dropdown from '~/components/Dropdown.vue'
|
import Dropdown from '~/components/Dropdown.vue'
|
||||||
import { toast } from '~/main'
|
import { toast } from '~/main'
|
||||||
import { fetchCurrentUser, getToken, setToken } from '~/utils/auth'
|
import { fetchCurrentUser, getToken, setToken } from '~/utils/auth'
|
||||||
|
import { frostedState, setFrosted } from '~/utils/frosted'
|
||||||
const config = useRuntimeConfig()
|
const config = useRuntimeConfig()
|
||||||
const API_BASE_URL = config.public.apiBaseUrl
|
const API_BASE_URL = config.public.apiBaseUrl
|
||||||
const username = ref('')
|
const username = ref('')
|
||||||
@@ -87,6 +95,7 @@ const aiFormatLimit = ref(3)
|
|||||||
const registerMode = ref('DIRECT')
|
const registerMode = ref('DIRECT')
|
||||||
const isLoadingPage = ref(false)
|
const isLoadingPage = ref(false)
|
||||||
const isSaving = ref(false)
|
const isSaving = ref(false)
|
||||||
|
const frosted = ref(true)
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
isLoadingPage.value = true
|
isLoadingPage.value = true
|
||||||
@@ -105,6 +114,7 @@ onMounted(async () => {
|
|||||||
navigateTo('/login', { replace: true })
|
navigateTo('/login', { replace: true })
|
||||||
}
|
}
|
||||||
isLoadingPage.value = false
|
isLoadingPage.value = false
|
||||||
|
frosted.value = frostedState.enabled
|
||||||
})
|
})
|
||||||
|
|
||||||
const onAvatarChange = (e) => {
|
const onAvatarChange = (e) => {
|
||||||
@@ -118,6 +128,7 @@ const onAvatarChange = (e) => {
|
|||||||
reader.readAsDataURL(file)
|
reader.readAsDataURL(file)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
watch(frosted, (val) => setFrosted(val))
|
||||||
const onCropped = ({ file, url }) => {
|
const onCropped = ({ file, url }) => {
|
||||||
avatarFile.value = file
|
avatarFile.value = file
|
||||||
avatar.value = url
|
avatar.value = url
|
||||||
@@ -300,6 +311,58 @@ const save = async () => {
|
|||||||
max-width: 200px;
|
max-width: 200px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.switch-row {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
max-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
width: 40px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch input {
|
||||||
|
opacity: 0;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider {
|
||||||
|
position: absolute;
|
||||||
|
cursor: pointer;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background-color: #ccc;
|
||||||
|
transition: 0.2s;
|
||||||
|
border-radius: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider:before {
|
||||||
|
position: absolute;
|
||||||
|
content: '';
|
||||||
|
height: 16px;
|
||||||
|
width: 16px;
|
||||||
|
left: 2px;
|
||||||
|
bottom: 2px;
|
||||||
|
background-color: white;
|
||||||
|
transition: 0.2s;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:checked + .slider {
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
input:checked + .slider:before {
|
||||||
|
transform: translateX(20px);
|
||||||
|
}
|
||||||
|
|
||||||
.profile-section {
|
.profile-section {
|
||||||
margin-bottom: 30px;
|
margin-bottom: 30px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,10 +35,12 @@
|
|||||||
/>
|
/>
|
||||||
<div class="profile-level-target">
|
<div class="profile-level-target">
|
||||||
目标 Lv.{{ levelInfo.currentLevel + 1 }}
|
目标 Lv.{{ levelInfo.currentLevel + 1 }}
|
||||||
<i
|
<ToolTip
|
||||||
class="fas fa-info-circle profile-exp-info"
|
content="经验值可通过发帖、评论等操作获得,达到目标后即可提升等级,解锁更多功能。"
|
||||||
title="经验值可通过发帖、评论等操作获得,达到目标后即可提升等级,解锁更多功能。"
|
placement="bottom"
|
||||||
></i>
|
>
|
||||||
|
<i class="fas fa-info-circle profile-exp-info"></i>
|
||||||
|
</ToolTip>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -204,67 +206,89 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="selectedTab === 'timeline'" class="profile-timeline">
|
<div v-else-if="selectedTab === 'timeline'" class="profile-timeline">
|
||||||
|
<div class="timeline-tabs">
|
||||||
|
<div
|
||||||
|
:class="['timeline-tab-item', { selected: timelineFilter === 'all' }]"
|
||||||
|
@click="timelineFilter = 'all'"
|
||||||
|
>
|
||||||
|
全部
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
:class="['timeline-tab-item', { selected: timelineFilter === 'articles' }]"
|
||||||
|
@click="timelineFilter = 'articles'"
|
||||||
|
>
|
||||||
|
文章
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
:class="['timeline-tab-item', { selected: timelineFilter === 'comments' }]"
|
||||||
|
@click="timelineFilter = 'comments'"
|
||||||
|
>
|
||||||
|
评论和回复
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<BasePlaceholder
|
<BasePlaceholder
|
||||||
v-if="timelineItems.length === 0"
|
v-if="filteredTimelineItems.length === 0"
|
||||||
text="暂无时间线"
|
text="暂无时间线"
|
||||||
icon="fas fa-inbox"
|
icon="fas fa-inbox"
|
||||||
/>
|
/>
|
||||||
<BaseTimeline :items="timelineItems">
|
<div class="timeline-list">
|
||||||
<template #item="{ item }">
|
<BaseTimeline :items="filteredTimelineItems">
|
||||||
<template v-if="item.type === 'post'">
|
<template #item="{ item }">
|
||||||
发布了文章
|
<template v-if="item.type === 'post'">
|
||||||
<router-link :to="`/posts/${item.post.id}`" class="timeline-link">
|
发布了文章
|
||||||
{{ item.post.title }}
|
<router-link :to="`/posts/${item.post.id}`" class="timeline-link">
|
||||||
</router-link>
|
{{ item.post.title }}
|
||||||
<div class="timeline-date">{{ formatDate(item.createdAt) }}</div>
|
</router-link>
|
||||||
|
<div class="timeline-date">{{ formatDate(item.createdAt) }}</div>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="item.type === 'comment'">
|
||||||
|
在
|
||||||
|
<router-link :to="`/posts/${item.comment.post.id}`" class="timeline-link">
|
||||||
|
{{ item.comment.post.title }}
|
||||||
|
</router-link>
|
||||||
|
下评论了
|
||||||
|
<router-link
|
||||||
|
:to="`/posts/${item.comment.post.id}#comment-${item.comment.id}`"
|
||||||
|
class="timeline-link"
|
||||||
|
>
|
||||||
|
{{ stripMarkdownLength(item.comment.content, 200) }}
|
||||||
|
</router-link>
|
||||||
|
<div class="timeline-date">{{ formatDate(item.createdAt) }}</div>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="item.type === 'reply'">
|
||||||
|
在
|
||||||
|
<router-link :to="`/posts/${item.comment.post.id}`" class="timeline-link">
|
||||||
|
{{ item.comment.post.title }}
|
||||||
|
</router-link>
|
||||||
|
下对
|
||||||
|
<router-link
|
||||||
|
:to="`/posts/${item.comment.post.id}#comment-${item.comment.parentComment.id}`"
|
||||||
|
class="timeline-link"
|
||||||
|
>
|
||||||
|
{{ stripMarkdownLength(item.comment.parentComment.content, 200) }}
|
||||||
|
</router-link>
|
||||||
|
回复了
|
||||||
|
<router-link
|
||||||
|
:to="`/posts/${item.comment.post.id}#comment-${item.comment.id}`"
|
||||||
|
class="timeline-link"
|
||||||
|
>
|
||||||
|
{{ stripMarkdownLength(item.comment.content, 200) }}
|
||||||
|
</router-link>
|
||||||
|
<div class="timeline-date">{{ formatDate(item.createdAt) }}</div>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="item.type === 'tag'">
|
||||||
|
创建了标签
|
||||||
|
<span class="timeline-link" @click="gotoTag(item.tag)">
|
||||||
|
{{ item.tag.name }}<span v-if="item.tag.count"> x{{ item.tag.count }}</span>
|
||||||
|
</span>
|
||||||
|
<div class="timeline-snippet" v-if="item.tag.description">
|
||||||
|
{{ item.tag.description }}
|
||||||
|
</div>
|
||||||
|
<div class="timeline-date">{{ formatDate(item.createdAt) }}</div>
|
||||||
|
</template>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="item.type === 'comment'">
|
</BaseTimeline>
|
||||||
在
|
</div>
|
||||||
<router-link :to="`/posts/${item.comment.post.id}`" class="timeline-link">
|
|
||||||
{{ item.comment.post.title }}
|
|
||||||
</router-link>
|
|
||||||
下评论了
|
|
||||||
<router-link
|
|
||||||
:to="`/posts/${item.comment.post.id}#comment-${item.comment.id}`"
|
|
||||||
class="timeline-link"
|
|
||||||
>
|
|
||||||
{{ stripMarkdownLength(item.comment.content, 200) }}
|
|
||||||
</router-link>
|
|
||||||
<div class="timeline-date">{{ formatDate(item.createdAt) }}</div>
|
|
||||||
</template>
|
|
||||||
<template v-else-if="item.type === 'reply'">
|
|
||||||
在
|
|
||||||
<router-link :to="`/posts/${item.comment.post.id}`" class="timeline-link">
|
|
||||||
{{ item.comment.post.title }}
|
|
||||||
</router-link>
|
|
||||||
下对
|
|
||||||
<router-link
|
|
||||||
:to="`/posts/${item.comment.post.id}#comment-${item.comment.parentComment.id}`"
|
|
||||||
class="timeline-link"
|
|
||||||
>
|
|
||||||
{{ stripMarkdownLength(item.comment.parentComment.content, 200) }}
|
|
||||||
</router-link>
|
|
||||||
回复了
|
|
||||||
<router-link
|
|
||||||
:to="`/posts/${item.comment.post.id}#comment-${item.comment.id}`"
|
|
||||||
class="timeline-link"
|
|
||||||
>
|
|
||||||
{{ stripMarkdownLength(item.comment.content, 200) }}
|
|
||||||
</router-link>
|
|
||||||
<div class="timeline-date">{{ formatDate(item.createdAt) }}</div>
|
|
||||||
</template>
|
|
||||||
<template v-else-if="item.type === 'tag'">
|
|
||||||
创建了标签
|
|
||||||
<span class="timeline-link" @click="gotoTag(item.tag)">
|
|
||||||
{{ item.tag.name }}<span v-if="item.tag.count"> x{{ item.tag.count }}</span>
|
|
||||||
</span>
|
|
||||||
<div class="timeline-snippet" v-if="item.tag.description">
|
|
||||||
{{ item.tag.description }}
|
|
||||||
</div>
|
|
||||||
<div class="timeline-date">{{ formatDate(item.createdAt) }}</div>
|
|
||||||
</template>
|
|
||||||
</template>
|
|
||||||
</BaseTimeline>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="selectedTab === 'following'" class="follow-container">
|
<div v-else-if="selectedTab === 'following'" class="follow-container">
|
||||||
@@ -324,6 +348,15 @@ const hotPosts = ref([])
|
|||||||
const hotReplies = ref([])
|
const hotReplies = ref([])
|
||||||
const hotTags = ref([])
|
const hotTags = ref([])
|
||||||
const timelineItems = ref([])
|
const timelineItems = ref([])
|
||||||
|
const timelineFilter = ref('all')
|
||||||
|
const filteredTimelineItems = computed(() => {
|
||||||
|
if (timelineFilter.value === 'articles') {
|
||||||
|
return timelineItems.value.filter((item) => item.type === 'post')
|
||||||
|
} else if (timelineFilter.value === 'comments') {
|
||||||
|
return timelineItems.value.filter((item) => item.type === 'comment' || item.type === 'reply')
|
||||||
|
}
|
||||||
|
return timelineItems.value
|
||||||
|
})
|
||||||
const followers = ref([])
|
const followers = ref([])
|
||||||
const followings = ref([])
|
const followings = ref([])
|
||||||
const medals = ref([])
|
const medals = ref([])
|
||||||
@@ -654,12 +687,6 @@ watch(selectedTab, async (val) => {
|
|||||||
opacity: 0.8;
|
opacity: 0.8;
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-exp-info {
|
|
||||||
margin-left: 4px;
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.profile-info {
|
.profile-info {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
@@ -700,6 +727,7 @@ watch(selectedTab, async (val) => {
|
|||||||
border-bottom: 1px solid var(--normal-border-color);
|
border-bottom: 1px solid var(--normal-border-color);
|
||||||
scrollbar-width: none;
|
scrollbar-width: none;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
|
backdrop-filter: var(--blur-10);
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-tabs-item {
|
.profile-tabs-item {
|
||||||
@@ -777,8 +805,24 @@ watch(selectedTab, async (val) => {
|
|||||||
width: 40%;
|
width: 40%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-timeline {
|
.timeline-tabs {
|
||||||
padding: 20px;
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
border-bottom: 1px solid var(--normal-border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-list {
|
||||||
|
padding: 10px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-tab-item {
|
||||||
|
padding: 10px 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-tab-item.selected {
|
||||||
|
color: var(--primary-color);
|
||||||
|
border-bottom: 2px solid var(--primary-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.timeline-date {
|
.timeline-date {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { defineNuxtPlugin } from 'nuxt/app'
|
||||||
import ClickOutside from '~/directives/clickOutside.js'
|
import ClickOutside from '~/directives/clickOutside.js'
|
||||||
|
|
||||||
export default defineNuxtPlugin((nuxtApp) => {
|
export default defineNuxtPlugin((nuxtApp) => {
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { defineNuxtPlugin } from 'nuxt/app'
|
||||||
|
import { initFrosted } from '~/utils/frosted'
|
||||||
|
|
||||||
|
export default defineNuxtPlugin(() => {
|
||||||
|
initFrosted()
|
||||||
|
})
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,7 +0,0 @@
|
|||||||
export default {
|
|
||||||
push(path) {
|
|
||||||
if (process.client) {
|
|
||||||
window.location.href = path
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { reactive } from 'vue'
|
||||||
|
|
||||||
|
const FROSTED_KEY = 'frosted-glass'
|
||||||
|
|
||||||
|
export const frostedState = reactive({
|
||||||
|
enabled: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
function apply() {
|
||||||
|
if (!import.meta.client) return
|
||||||
|
document.documentElement.dataset.frosted = frostedState.enabled ? 'on' : 'off'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initFrosted() {
|
||||||
|
if (!import.meta.client) return
|
||||||
|
const saved = localStorage.getItem(FROSTED_KEY)
|
||||||
|
frostedState.enabled = saved !== 'false'
|
||||||
|
apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setFrosted(enabled) {
|
||||||
|
if (!import.meta.client) return
|
||||||
|
frostedState.enabled = enabled
|
||||||
|
localStorage.setItem(FROSTED_KEY, enabled ? 'true' : 'false')
|
||||||
|
apply()
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@ export async function googleGetIdToken() {
|
|||||||
export function googleAuthorize() {
|
export function googleAuthorize() {
|
||||||
const config = useRuntimeConfig()
|
const config = useRuntimeConfig()
|
||||||
const GOOGLE_CLIENT_ID = config.public.googleClientId
|
const GOOGLE_CLIENT_ID = config.public.googleClientId
|
||||||
|
const WEBSITE_BASE_URL = config.public.websiteBaseUrl
|
||||||
if (!GOOGLE_CLIENT_ID) {
|
if (!GOOGLE_CLIENT_ID) {
|
||||||
toast.error('Google 登录不可用, 请检查网络设置与VPN')
|
toast.error('Google 登录不可用, 请检查网络设置与VPN')
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -1,5 +1,14 @@
|
|||||||
import hljs from 'highlight.js'
|
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 MarkdownIt from 'markdown-it'
|
||||||
import { toast } from '../main'
|
import { toast } from '../main'
|
||||||
import { tiebaEmoji } from './tiebaEmoji'
|
import { tiebaEmoji } from './tiebaEmoji'
|
||||||
@@ -86,7 +95,11 @@ const md = new MarkdownIt({
|
|||||||
} else {
|
} else {
|
||||||
code = hljs.highlightAuto(str).value
|
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>`
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,32 @@
|
|||||||
import { getToken } from './auth'
|
import { navigateTo, useRuntimeConfig } from 'nuxt/app'
|
||||||
import { reactive } from 'vue'
|
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({
|
export const notificationState = reactive({
|
||||||
unreadCount: 0,
|
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() {
|
export async function fetchUnreadCount() {
|
||||||
const config = useRuntimeConfig()
|
const config = useRuntimeConfig()
|
||||||
const API_BASE_URL = config.public.apiBaseUrl
|
const API_BASE_URL = config.public.apiBaseUrl
|
||||||
@@ -87,3 +109,235 @@ export async function updateNotificationPreference(type, enabled) {
|
|||||||
return false
|
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()
|
||||||
|
|||||||
+162
-12
@@ -14,19 +14,46 @@ export const themeState = reactive({
|
|||||||
})
|
})
|
||||||
|
|
||||||
function apply(mode) {
|
function apply(mode) {
|
||||||
if (!process.client) return
|
if (!import.meta.client) return
|
||||||
const root = document.documentElement
|
const root = document.documentElement
|
||||||
if (mode === ThemeMode.SYSTEM) {
|
let newMode =
|
||||||
root.dataset.theme = window.matchMedia('(prefers-color-scheme: dark)').matches
|
mode === ThemeMode.SYSTEM
|
||||||
? 'dark'
|
? window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||||
: 'light'
|
? '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 {
|
} 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() {
|
export function initTheme() {
|
||||||
if (!process.client) return
|
if (!import.meta.client) return
|
||||||
const saved = localStorage.getItem(THEME_KEY)
|
const saved = localStorage.getItem(THEME_KEY)
|
||||||
if (saved && Object.values(ThemeMode).includes(saved)) {
|
if (saved && Object.values(ThemeMode).includes(saved)) {
|
||||||
themeState.mode = saved
|
themeState.mode = saved
|
||||||
@@ -35,15 +62,117 @@ export function initTheme() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function setTheme(mode) {
|
export function setTheme(mode) {
|
||||||
if (!process.client) return
|
if (!import.meta.client) return
|
||||||
if (!Object.values(ThemeMode).includes(mode)) return
|
if (!Object.values(ThemeMode).includes(mode)) return
|
||||||
themeState.mode = mode
|
themeState.mode = mode
|
||||||
localStorage.setItem(THEME_KEY, mode)
|
localStorage.setItem(THEME_KEY, mode)
|
||||||
apply(mode)
|
apply(mode)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function cycleTheme() {
|
function getCircle(event) {
|
||||||
if (!process.client) return
|
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: var(--blur-1);
|
||||||
|
`
|
||||||
|
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 modes = [ThemeMode.SYSTEM, ThemeMode.LIGHT, ThemeMode.DARK]
|
||||||
const index = modes.indexOf(themeState.mode)
|
const index = modes.indexOf(themeState.mode)
|
||||||
const next = modes[(index + 1) % modes.length]
|
const next = modes[(index + 1) % modes.length]
|
||||||
@@ -54,10 +183,31 @@ export function cycleTheme() {
|
|||||||
} else {
|
} else {
|
||||||
toast.success('🌙 已经切换到暗色主题')
|
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', () => {
|
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
|
||||||
if (themeState.mode === ThemeMode.SYSTEM) {
|
if (themeState.mode === ThemeMode.SYSTEM) {
|
||||||
apply(ThemeMode.SYSTEM)
|
apply(ThemeMode.SYSTEM)
|
||||||
|
|||||||
Reference in New Issue
Block a user