mirror of
https://github.com/nagisa77/OpenIsle.git
synced 2026-02-27 00:20:48 +08:00
Compare commits
47 Commits
feature/ui
...
codex/save
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
5126cfda8c | ||
|
|
04ff17f796 |
23
.github/workflows/deploy-staging.yml
vendored
Normal file
23
.github/workflows/deploy-staging.yml
vendored
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
name: Staging CI & CD
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
environment: Deploy
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Deploy to Server
|
||||||
|
uses: appleboy/ssh-action@v1.0.3
|
||||||
|
with:
|
||||||
|
host: ${{ secrets.SSH_HOST }}
|
||||||
|
username: root
|
||||||
|
key: ${{ secrets.SSH_KEY }}
|
||||||
|
script: bash /opt/openisle/deploy-staging.sh
|
||||||
|
|
||||||
20
.github/workflows/deploy.yml
vendored
20
.github/workflows/deploy.yml
vendored
@@ -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:
|
||||||
|
|||||||
25
README.md
25
README.md
@@ -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,9 +75,11 @@ 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",
|
||||||
|
|||||||
@@ -1,4 +1,13 @@
|
|||||||
|
; 本地部署后端
|
||||||
|
; 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-nt8afkkap18gui4f9entcha99unal744.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
|
||||||
|
|||||||
16
frontend_nuxt/.env.staging.example
Normal file
16
frontend_nuxt/.env.staging.example
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
; 本地部署后端
|
||||||
|
; NUXT_PUBLIC_API_BASE_URL=https://127.0.0.1:8081
|
||||||
|
; 预发环境后端
|
||||||
|
NUXT_PUBLIC_API_BASE_URL=https://staging.open-isle.com
|
||||||
|
; 生产环境后端
|
||||||
|
; NUXT_PUBLIC_API_BASE_URL=https://www.open-isle.com
|
||||||
|
|
||||||
|
; 预发环境
|
||||||
|
NUXT_PUBLIC_WEBSITE_BASE_URL=https://staging.open-isle.com
|
||||||
|
; 正式环境/生产环境
|
||||||
|
; NUXT_PUBLIC_WEBSITE_BASE_URL=https://www.open-isle.com
|
||||||
|
|
||||||
|
NUXT_PUBLIC_GOOGLE_CLIENT_ID=777830451304-nt8afkkap18gui4f9entcha99unal744.apps.googleusercontent.com
|
||||||
|
NUXT_PUBLIC_GITHUB_CLIENT_ID=Ov23liVkO1NPAX5JyWxJ
|
||||||
|
NUXT_PUBLIC_DISCORD_CLIENT_ID=1394985417044000779
|
||||||
|
NUXT_PUBLIC_TWITTER_CLIENT_ID=ZTRTU05KSk9KTTJrTTdrVC1tc1E6MTpjaQ
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
<NuxtPage keepalive />
|
<NuxtPage keepalive />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="showNewPostIcon && isMobile" class="new-post-icon" @click="goToNewPost">
|
<div v-if="showNewPostIcon && isMobile" class="app-new-post-icon" @click="goToNewPost">
|
||||||
<i class="fas fa-edit"></i>
|
<i class="fas fa-edit"></i>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -74,7 +74,18 @@ const goToNewPost = () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style src="~/assets/global.css"></style>
|
<style src="~/assets/global.css"></style>
|
||||||
<style scoped>
|
<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;
|
||||||
@@ -107,7 +118,7 @@ const goToNewPost = () => {
|
|||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.new-post-icon {
|
.app-new-post-icon {
|
||||||
background-color: var(--new-post-icon-color);
|
background-color: var(--new-post-icon-color);
|
||||||
color: white;
|
color: white;
|
||||||
width: 60px;
|
width: 60px;
|
||||||
|
|||||||
@@ -7,7 +7,8 @@
|
|||||||
--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;
|
/* 加一个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);
|
--background-color-blur: var(--background-color);
|
||||||
@@ -36,14 +37,14 @@
|
|||||||
--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);
|
||||||
--menu-border-color: #555;
|
--menu-border-color: #555;
|
||||||
--normal-border-color: #555;
|
--normal-border-color: #555;
|
||||||
--new-post-icon-color: rgba(10, 111, 120, 0.598);
|
|
||||||
--menu-selected-background-color: rgba(255, 255, 255, 0.1);
|
--menu-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; */
|
||||||
@@ -305,6 +306,29 @@ body {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Transition API */
|
||||||
|
::view-transition-old(root),
|
||||||
|
::view-transition-new(root) {
|
||||||
|
animation: none;
|
||||||
|
mix-blend-mode: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
::view-transition-old(root) {
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
::view-transition-new(root) {
|
||||||
|
z-index: 2147483646;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme='dark']::view-transition-old(root) {
|
||||||
|
z-index: 2147483646;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme='dark']::view-transition-new(root) {
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
/* NProgress styles */
|
/* NProgress styles */
|
||||||
#nprogress {
|
#nprogress {
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -84,6 +97,7 @@ export default {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
cursor: pointer;
|
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="`/`">
|
<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,18 +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>
|
||||||
|
|
||||||
<ToolTip v-if="!isMobile" content="发帖" placement="bottom">
|
<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">
|
<div class="new-post-icon" @click="goToNewPost">
|
||||||
<i class="fas fa-edit"></i>
|
<i class="fas fa-edit"></i>
|
||||||
</div>
|
</div>
|
||||||
</ToolTip>
|
</ToolTip>
|
||||||
|
|
||||||
<DropdownMenu ref="userMenu" :items="headerMenuItems">
|
<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" />
|
||||||
@@ -39,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>
|
||||||
|
|
||||||
@@ -64,6 +65,8 @@ 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,
|
||||||
@@ -125,12 +128,29 @@ const goToNewPost = () => {
|
|||||||
navigateTo('/new-post', { replace: false })
|
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) {
|
||||||
@@ -164,8 +184,8 @@ onMounted(async () => {
|
|||||||
<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: blur(10px);
|
||||||
@@ -187,10 +207,8 @@ 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -201,6 +219,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;
|
||||||
@@ -282,7 +308,8 @@ 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;
|
font-size: 18px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -115,20 +115,27 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="menu-footer">
|
|
||||||
<div class="menu-footer-btn" @click="cycleTheme">
|
<!-- 解决动态样式的水合错误 -->
|
||||||
<i :class="iconClass"></i>
|
<ClientOnly v-if="!isMobile">
|
||||||
|
<div class="menu-footer">
|
||||||
|
<div class="menu-footer-btn" @click="cycleTheme">
|
||||||
|
<i :class="iconClass"></i>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</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 } 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
|
||||||
@@ -225,7 +232,7 @@ const gotoTag = (t) => {
|
|||||||
position: sticky;
|
position: sticky;
|
||||||
top: var(--header-height);
|
top: var(--header-height);
|
||||||
width: 220px;
|
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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
@@ -135,7 +135,7 @@ defineExpose({
|
|||||||
}
|
}
|
||||||
|
|
||||||
.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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export default defineNuxtConfig({
|
|||||||
// 确保 Vditor 样式在 global.css 覆盖前加载
|
// 确保 Vditor 样式在 global.css 覆盖前加载
|
||||||
css: ['vditor/dist/index.css', '~/assets/fonts.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);
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
`,
|
`,
|
||||||
},
|
},
|
||||||
@@ -60,4 +85,27 @@ export default defineNuxtConfig({
|
|||||||
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'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
4950
frontend_nuxt/package-lock.json
generated
4950
frontend_nuxt/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -150,6 +150,13 @@ const topics = ref(['最新回复', '最新', '排行榜' /*, '热门', '类别'
|
|||||||
const selectedTopic = ref(
|
const selectedTopic = ref(
|
||||||
route.query.view === 'ranking' ? '排行榜' : route.query.view === 'latest' ? '最新' : '最新回复',
|
route.query.view === 'ranking' ? '排行榜' : route.query.view === 'latest' ? '最新' : '最新回复',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (import.meta.client) {
|
||||||
|
const storedTopic = localStorage.getItem('home-selected-topic')
|
||||||
|
if (storedTopic && topics.value.includes(storedTopic)) {
|
||||||
|
selectedTopic.value = storedTopic
|
||||||
|
}
|
||||||
|
}
|
||||||
const articles = ref([])
|
const articles = ref([])
|
||||||
const page = ref(0)
|
const page = ref(0)
|
||||||
const pageSize = 10
|
const pageSize = 10
|
||||||
@@ -340,7 +347,10 @@ watch(
|
|||||||
watch([selectedCategory, selectedTags], () => {
|
watch([selectedCategory, selectedTags], () => {
|
||||||
loadOptions()
|
loadOptions()
|
||||||
})
|
})
|
||||||
watch(selectedTopic, () => {
|
watch(selectedTopic, (topic) => {
|
||||||
|
if (import.meta.client) {
|
||||||
|
localStorage.setItem('home-selected-topic', topic)
|
||||||
|
}
|
||||||
// 仅当需要额外选项时加载
|
// 仅当需要额外选项时加载
|
||||||
loadOptions()
|
loadOptions()
|
||||||
})
|
})
|
||||||
@@ -351,6 +361,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)
|
||||||
})
|
})
|
||||||
|
|
||||||
/** 其他工具函数 **/
|
/** 其他工具函数 **/
|
||||||
@@ -381,7 +393,6 @@ const sanitizeDescription = (text) => stripMarkdown(text)
|
|||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.loading-container {
|
.loading-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -535,6 +546,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;
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -867,6 +867,7 @@ onMounted(async () => {
|
|||||||
direction: ltr;
|
direction: ltr;
|
||||||
height: 300px;
|
height: 300px;
|
||||||
width: 2px;
|
width: 2px;
|
||||||
|
appearance: none;
|
||||||
-webkit-appearance: none;
|
-webkit-appearance: none;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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: blur(1px);
|
||||||
|
`
|
||||||
|
document.body.appendChild(transitionElement)
|
||||||
|
|
||||||
|
// 使用 Web Animations API 实现淡出动画
|
||||||
|
const animation = transitionElement.animate([{ opacity: 1 }, { opacity: 0 }], {
|
||||||
|
duration: 300,
|
||||||
|
easing: 'ease-out',
|
||||||
|
})
|
||||||
|
|
||||||
|
// 应用主题变更
|
||||||
|
applyFn()
|
||||||
|
|
||||||
|
// 动画完成后清理
|
||||||
|
animation.finished
|
||||||
|
.then(() => {
|
||||||
|
document.body.removeChild(transitionElement)
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// 降级处理
|
||||||
|
document.body.removeChild(transitionElement)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSystemTheme() {
|
||||||
|
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cycleTheme(event) {
|
||||||
|
if (!import.meta.client) return
|
||||||
const modes = [ThemeMode.SYSTEM, ThemeMode.LIGHT, ThemeMode.DARK]
|
const 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