Compare commits

...

35 Commits

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

- Wrap dropdown in Transition component with slide effect

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

- Update cycleTheme to handle animation circle

- Refactor CSS with consistent quoting and indentation

- Improve theme variable handling and no-op optimizations

- Pass event to cycleTheme in MenuComponent
2025-08-15 13:12:27 +08:00
Tim
f025e82e7c Merge pull request #580 from nagisa77/codex/resolve-chunk-size-warning-issue
chore: split large vite chunks
2025-08-15 13:11:00 +08:00
19 changed files with 380 additions and 77 deletions

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

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

View File

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

View File

@@ -75,9 +75,11 @@ public class SecurityConfig {
cfg.setAllowedOrigins(List.of(
"http://127.0.0.1:8080",
"http://127.0.0.1:3000",
"http://127.0.0.1:3001",
"http://127.0.0.1",
"http://localhost:8080",
"http://localhost:3000",
"http://localhost:3001",
"http://localhost",
"http://30.211.97.238:3000",
"http://30.211.97.238",

View File

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

View File

@@ -0,0 +1,16 @@
; 本地部署后端
; NUXT_PUBLIC_API_BASE_URL=https://127.0.0.1:8081
; 预发环境后端
NUXT_PUBLIC_API_BASE_URL=https://staging.open-isle.com
; 生产环境后端
; NUXT_PUBLIC_API_BASE_URL=https://www.open-isle.com
; 预发环境
NUXT_PUBLIC_WEBSITE_BASE_URL=https://staging.open-isle.com
; 正式环境/生产环境
; NUXT_PUBLIC_WEBSITE_BASE_URL=https://www.open-isle.com
NUXT_PUBLIC_GOOGLE_CLIENT_ID=777830451304-nt8afkkap18gui4f9entcha99unal744.apps.googleusercontent.com
NUXT_PUBLIC_GITHUB_CLIENT_ID=Ov23liVkO1NPAX5JyWxJ
NUXT_PUBLIC_DISCORD_CLIENT_ID=1394985417044000779
NUXT_PUBLIC_TWITTER_CLIENT_ID=ZTRTU05KSk9KTTJrTTdrVC1tc1E6MTpjaQ

View File

@@ -16,7 +16,7 @@
<NuxtPage keepalive />
</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>
</div>
</div>
@@ -74,7 +74,18 @@ const goToNewPost = () => {
</script>
<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 {
position: fixed;
top: 0;
@@ -107,7 +118,7 @@ const goToNewPost = () => {
margin: 0 auto;
}
.new-post-icon {
.app-new-post-icon {
background-color: var(--new-post-icon-color);
color: white;
width: 60px;

View File

@@ -7,7 +7,8 @@
--header-background-color: white;
--header-border-color: lightgray;
--header-text-color: black;
--menu-background-color: white;
/* 加一个app前缀防止与firefox的userChrome.css中的--menu-background-color冲突 */
--app-menu-background-color: white;
--background-color: white;
/* --background-color-blur: rgba(255, 255, 255, 0.57); */
--background-color-blur: var(--background-color);
@@ -36,14 +37,14 @@
--header-border-color: #555;
--primary-color: rgb(17, 182, 197);
--primary-color-hover: rgb(13, 137, 151);
--new-post-icon-color: rgba(10, 111, 120, 0.598);
--header-text-color: white;
--menu-background-color: #333;
--app-menu-background-color: #333;
--background-color: #333;
/* --background-color-blur: #333333a4; */
--background-color-blur: var(--background-color);
--menu-border-color: #555;
--normal-border-color: #555;
--new-post-icon-color: rgba(10, 111, 120, 0.598);
--menu-selected-background-color: rgba(255, 255, 255, 0.1);
--menu-text-color: white;
/* --normal-background-color: #000000; */
@@ -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 {
pointer-events: none;

View File

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

View File

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

View File

@@ -20,18 +20,22 @@
</div>
<ClientOnly>
<div v-if="isLogin" class="header-content-right">
<div class="header-content-right">
<div v-if="isMobile" class="search-icon" @click="search">
<i class="fas fa-search"></i>
</div>
<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">
<i class="fas fa-edit"></i>
</div>
</ToolTip>
<DropdownMenu ref="userMenu" :items="headerMenuItems">
<DropdownMenu v-if="isLogin" ref="userMenu" :items="headerMenuItems">
<template #trigger>
<div class="avatar-container">
<img class="avatar-img" :src="avatar" alt="avatar" />
@@ -39,14 +43,11 @@
</div>
</template>
</DropdownMenu>
</div>
<div v-else class="header-content-right">
<div v-if="isMobile" class="search-icon" @click="search">
<i class="fas fa-search"></i>
<div v-if="!isLogin" class="auth-btns">
<div class="header-content-item-main" @click="goToLogin">登录</div>
<div class="header-content-item-secondary" @click="goToSignup">注册</div>
</div>
<div class="header-content-item-main" @click="goToLogin">登录</div>
<div class="header-content-item-secondary" @click="goToSignup">注册</div>
</div>
</ClientOnly>
@@ -64,6 +65,8 @@ import SearchDropdown from '~/components/SearchDropdown.vue'
import { authState, clearToken, loadCurrentUser } from '~/utils/auth'
import { fetchUnreadCount, notificationState } from '~/utils/notification'
import { useIsMobile } from '~/utils/screen'
import { themeState, cycleTheme, ThemeMode } from '~/utils/theme'
const props = defineProps({
showMenuBtn: {
type: Boolean,
@@ -127,6 +130,7 @@ const goToNewPost = () => {
const refrechData = async () => {
await fetchUnreadCount()
window.dispatchEvent(new Event('refresh-home'))
}
const headerMenuItems = computed(() => [
@@ -135,6 +139,18 @@ const headerMenuItems = computed(() => [
{ text: '退出', onClick: goToLogout },
])
/** 其余逻辑保持不变 */
const iconClass = computed(() => {
switch (themeState.mode) {
case ThemeMode.DARK:
return 'fas fa-moon'
case ThemeMode.LIGHT:
return 'fas fa-sun'
default:
return 'fas fa-desktop'
}
})
onMounted(async () => {
const updateAvatar = async () => {
if (authState.loggedIn) {
@@ -168,8 +184,8 @@ onMounted(async () => {
<style scoped>
.header {
display: flex;
justify-content: space-between;
align-items: center;
justify-content: center;
height: var(--header-height);
background-color: var(--background-color-blur);
backdrop-filter: blur(10px);
@@ -191,10 +207,8 @@ onMounted(async () => {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
width: 100%;
height: 100%;
margin: 0 auto;
max-width: var(--page-max-width);
}
@@ -205,6 +219,14 @@ onMounted(async () => {
}
.header-content-right {
display: flex;
margin-left: auto;
flex-direction: row;
align-items: center;
gap: 20px;
}
.auth-btns {
display: flex;
flex-direction: row;
align-items: center;
@@ -286,7 +308,8 @@ onMounted(async () => {
background-color: var(--menu-selected-background-color);
}
.search-icon {
.search-icon,
.theme-icon {
font-size: 18px;
cursor: pointer;
}

View File

@@ -117,7 +117,7 @@
</div>
<!-- 解决动态样式的水合错误 -->
<ClientOnly>
<ClientOnly v-if="!isMobile">
<div class="menu-footer">
<div class="menu-footer-btn" @click="cycleTheme">
<i :class="iconClass"></i>
@@ -129,10 +129,13 @@
</template>
<script setup>
import { ref, computed, watch, onMounted } from 'vue'
import { themeState, cycleTheme, ThemeMode } from '~/utils/theme'
import { computed, onMounted, ref, watch } from 'vue'
import { authState } from '~/utils/auth'
import { fetchUnreadCount, notificationState } from '~/utils/notification'
import { useIsMobile } from '~/utils/screen'
import { cycleTheme, ThemeMode, themeState } from '~/utils/theme'
const isMobile = useIsMobile()
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
@@ -229,7 +232,7 @@ const gotoTag = (t) => {
position: sticky;
top: var(--header-height);
width: 220px;
background-color: var(--menu-background-color);
background-color: var(--app-menu-background-color);
height: calc(100vh - 20px - var(--header-height));
border-right: 1px solid var(--menu-border-color);
display: flex;

View File

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

View File

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

View File

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

View File

@@ -5946,9 +5946,9 @@
"license": "MIT"
},
"node_modules/electron-to-chromium": {
"version": "1.5.201",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.201.tgz",
"integrity": "sha512-ZG65vsrLClodGqywuigc+7m0gr4ISoTQttfVh7nfpLv0M7SIwF4WbFNEOywcqTiujs12AUeeXbFyQieDICAIxg==",
"version": "1.5.202",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.202.tgz",
"integrity": "sha512-NxbYjRmiHcHXV1Ws3fWUW+SLb62isauajk45LUJ/HgIOkUA7jLZu/X2Iif+X9FBNK8QkF9Zb4Q2mcwXCcY30mg==",
"license": "ISC"
},
"node_modules/emoji-regex": {

View File

@@ -150,6 +150,13 @@ const topics = ref(['最新回复', '最新', '排行榜' /*, '热门', '类别'
const selectedTopic = ref(
route.query.view === 'ranking' ? '排行榜' : route.query.view === 'latest' ? '最新' : '最新回复',
)
if (import.meta.client) {
const storedTopic = localStorage.getItem('home-selected-topic')
if (storedTopic && topics.value.includes(storedTopic)) {
selectedTopic.value = storedTopic
}
}
const articles = ref([])
const page = ref(0)
const pageSize = 10
@@ -340,7 +347,10 @@ watch(
watch([selectedCategory, selectedTags], () => {
loadOptions()
})
watch(selectedTopic, () => {
watch(selectedTopic, (topic) => {
if (import.meta.client) {
localStorage.setItem('home-selected-topic', topic)
}
// 仅当需要额外选项时加载
loadOptions()
})
@@ -351,6 +361,8 @@ if (import.meta.server) {
}
onMounted(() => {
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;
}
.loading-container {
display: flex;
justify-content: center;
@@ -535,6 +546,7 @@ const sanitizeDescription = (text) => stripMarkdown(text)
font-size: 14px;
color: gray;
display: -webkit-box;
line-clamp: 3;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;

View File

@@ -636,6 +636,8 @@ onActivated(() => {
.message-page {
background-color: var(--background-color);
overflow-x: hidden;
height: calc(100vh - var(--header-height));
overflow-y: auto;
}
.message-page-header {

View File

@@ -867,6 +867,7 @@ onMounted(async () => {
direction: ltr;
height: 300px;
width: 2px;
appearance: none;
-webkit-appearance: none;
background: transparent;
}

View File

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