mirror of
https://github.com/nagisa77/OpenIsle.git
synced 2026-02-10 00:51:00 +08:00
Compare commits
35 Commits
codex/reso
...
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 |
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
|
||||
|
||||
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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
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 />
|
||||
</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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
})();
|
||||
`,
|
||||
},
|
||||
|
||||
6
frontend_nuxt/package-lock.json
generated
6
frontend_nuxt/package-lock.json
generated
@@ -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": {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -867,6 +867,7 @@ onMounted(async () => {
|
||||
direction: ltr;
|
||||
height: 300px;
|
||||
width: 2px;
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user