mirror of
https://github.com/nagisa77/OpenIsle.git
synced 2026-02-07 07:30:54 +08:00
Compare commits
43 Commits
codex/fix-
...
feature/ui
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
423248c574 | ||
|
|
e009875797 | ||
|
|
e9c9fbd742 | ||
|
|
b385945c2d | ||
|
|
24cbed2eda | ||
|
|
ba073b71a6 | ||
|
|
5ff098ea21 | ||
|
|
f6713b956e | ||
|
|
b8ea12646f | ||
|
|
e573e54c2b | ||
|
|
8ec005d392 | ||
|
|
b1f92f61a6 | ||
|
|
824b4dd8aa | ||
|
|
6b08db7e58 | ||
|
|
6f3830b3f7 | ||
|
|
d70dad723f | ||
|
|
2cf89e4802 | ||
|
|
1fc6460ae0 | ||
|
|
a04e5c2f6f | ||
|
|
77b26937f5 | ||
|
|
a1134b9d4b | ||
|
|
600f6ac1d1 | ||
|
|
9ad50b35c9 | ||
|
|
867ee3907b | ||
|
|
58fcd42745 | ||
|
|
0ee62a3a04 | ||
|
|
f0bc7a22a0 | ||
|
|
f6c0c8e226 | ||
|
|
8f3c0d6710 | ||
|
|
4f738778db | ||
|
|
84b45f785d | ||
|
|
df56d7e885 | ||
|
|
76176e135c | ||
|
|
ab87e0e51c | ||
|
|
5346a063bf | ||
|
|
e53f2130b8 | ||
|
|
1e87e9252d | ||
|
|
3fc4d29dce | ||
|
|
bcdac9d9b2 | ||
|
|
ea9710d16f | ||
|
|
1a1b20b9cf | ||
|
|
b63ebb8fae | ||
|
|
e0f7299a86 |
@@ -81,8 +81,8 @@ public class SecurityConfig {
|
||||
"http://localhost",
|
||||
"http://30.211.97.238:3000",
|
||||
"http://30.211.97.238",
|
||||
"http://192.168.7.70",
|
||||
"http://192.168.7.70:8080",
|
||||
"http://192.168.7.98",
|
||||
"http://192.168.7.98:3000",
|
||||
websiteUrl,
|
||||
websiteUrl.replace("://www.", "://")
|
||||
));
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
NUXT_PUBLIC_API_BASE_URL=https://www.open-isle.com
|
||||
NUXT_PUBLIC_WEBSITE_BASE_URL=https://www.open-isle.com
|
||||
NUXT_PUBLIC_GOOGLE_CLIENT_ID=777830451304-xxx.apps.googleusercontent.com
|
||||
NUXT_PUBLIC_GOOGLE_CLIENT_ID=777830451304-nt8afkkap18gui4f9entcha99unal744.apps.googleusercontent.com
|
||||
NUXT_PUBLIC_GITHUB_CLIENT_ID=Ov23liVkO1NPAX5JyWxJ
|
||||
NUXT_PUBLIC_DISCORD_CLIENT_ID=1394985417044000779
|
||||
NUXT_PUBLIC_TWITTER_CLIENT_ID=ZTRTU05KSk9KTTJrTTdrVC1tc1E6MTpjaQ
|
||||
|
||||
@@ -15,62 +15,66 @@
|
||||
<div class="content" :class="{ 'menu-open': menuVisible && !hideMenu }">
|
||||
<NuxtPage keepalive />
|
||||
</div>
|
||||
|
||||
<div v-if="showNewPostIcon && isMobile" class="new-post-icon" @click="goToNewPost">
|
||||
<i class="fas fa-edit"></i>
|
||||
</div>
|
||||
</div>
|
||||
<GlobalPopups />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script setup>
|
||||
import HeaderComponent from '~/components/HeaderComponent.vue'
|
||||
import MenuComponent from '~/components/MenuComponent.vue'
|
||||
|
||||
import GlobalPopups from '~/components/GlobalPopups.vue'
|
||||
import { useIsMobile } from '~/utils/screen'
|
||||
|
||||
export default {
|
||||
name: 'App',
|
||||
components: { HeaderComponent, MenuComponent, GlobalPopups },
|
||||
setup() {
|
||||
const isMobile = useIsMobile()
|
||||
const menuVisible = ref(!isMobile.value)
|
||||
const hideMenu = computed(() => {
|
||||
return [
|
||||
'/login',
|
||||
'/signup',
|
||||
'/404',
|
||||
'/signup-reason',
|
||||
'/github-callback',
|
||||
'/twitter-callback',
|
||||
'/discord-callback',
|
||||
'/forgot-password',
|
||||
'/google-callback',
|
||||
].includes(useRoute().path)
|
||||
})
|
||||
const isMobile = useIsMobile()
|
||||
const menuVisible = ref(!isMobile.value)
|
||||
|
||||
const header = useTemplateRef('header')
|
||||
const showNewPostIcon = computed(() => useRoute().path === '/')
|
||||
|
||||
onMounted(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
menuVisible.value = window.innerWidth > 768
|
||||
}
|
||||
})
|
||||
const hideMenu = computed(() => {
|
||||
return [
|
||||
'/login',
|
||||
'/signup',
|
||||
'/404',
|
||||
'/signup-reason',
|
||||
'/github-callback',
|
||||
'/twitter-callback',
|
||||
'/discord-callback',
|
||||
'/forgot-password',
|
||||
'/google-callback',
|
||||
].includes(useRoute().path)
|
||||
})
|
||||
|
||||
const handleMenuOutside = (event) => {
|
||||
const btn = header.value.$refs.menuBtn
|
||||
if (btn && (btn === event.target || btn.contains(event.target))) {
|
||||
return // 如果是菜单按钮的点击,不处理关闭
|
||||
}
|
||||
const header = useTemplateRef('header')
|
||||
|
||||
if (isMobile.value) {
|
||||
menuVisible.value = false
|
||||
}
|
||||
}
|
||||
onMounted(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
menuVisible.value = window.innerWidth > 768
|
||||
}
|
||||
})
|
||||
|
||||
return { menuVisible, hideMenu, handleMenuOutside, header }
|
||||
},
|
||||
const handleMenuOutside = (event) => {
|
||||
const btn = header.value.$refs.menuBtn
|
||||
if (btn && (btn === event.target || btn.contains(event.target))) {
|
||||
return // 如果是菜单按钮的点击,不处理关闭
|
||||
}
|
||||
|
||||
if (isMobile.value) {
|
||||
menuVisible.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const goToNewPost = () => {
|
||||
navigateTo('/new-post', { replace: false })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style src="~/assets/global.css"></style>
|
||||
<style>
|
||||
<style scoped>
|
||||
.header-container {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
@@ -103,6 +107,24 @@ export default {
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.new-post-icon {
|
||||
background-color: var(--new-post-icon-color);
|
||||
color: white;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 50%;
|
||||
position: fixed;
|
||||
bottom: 40px;
|
||||
right: 20px;
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
backdrop-filter: blur(5px);
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.content,
|
||||
.content.menu-open {
|
||||
|
||||
143
frontend_nuxt/assets/fonts.css
Normal file
143
frontend_nuxt/assets/fonts.css
Normal file
@@ -0,0 +1,143 @@
|
||||
/* Maple Mono - Thin 100 */
|
||||
@font-face {
|
||||
font-family: "Maple Mono";
|
||||
src: url("/fonts/maple-mono-100-normal.woff2") format("woff2");
|
||||
font-weight: 100;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Maple Mono - Thin Italic 100 */
|
||||
@font-face {
|
||||
font-family: "Maple Mono";
|
||||
src: url("/fonts/maple-mono-100-italic.woff2") format("woff2");
|
||||
font-weight: 100;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Maple Mono - ExtraLight 200 */
|
||||
@font-face {
|
||||
font-family: "Maple Mono";
|
||||
src: url("/fonts/maple-mono-200-normal.woff2") format("woff2");
|
||||
font-weight: 200;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Maple Mono - ExtraLight Italic 200 */
|
||||
@font-face {
|
||||
font-family: "Maple Mono";
|
||||
src: url("/fonts/maple-mono-200-italic.woff2") format("woff2");
|
||||
font-weight: 200;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Maple Mono - Light 300 */
|
||||
@font-face {
|
||||
font-family: "Maple Mono";
|
||||
src: url("/fonts/maple-mono-300-normal.woff2") format("woff2");
|
||||
font-weight: 300;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Maple Mono - Light Italic 300 */
|
||||
@font-face {
|
||||
font-family: "Maple Mono";
|
||||
src: url("/fonts/maple-mono-300-italic.woff2") format("woff2");
|
||||
font-weight: 300;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Maple Mono - Regular 400 */
|
||||
@font-face {
|
||||
font-family: "Maple Mono";
|
||||
src: url("/fonts/maple-mono-400-normal.woff2") format("woff2");
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Maple Mono - Italic 400 */
|
||||
@font-face {
|
||||
font-family: "Maple Mono";
|
||||
src: url("/fonts/maple-mono-400-italic.woff2") format("woff2");
|
||||
font-weight: 400;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Maple Mono - Medium 500 */
|
||||
@font-face {
|
||||
font-family: "Maple Mono";
|
||||
src: url("/fonts/maple-mono-500-normal.woff2") format("woff2");
|
||||
font-weight: 500;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Maple Mono - Medium Italic 500 */
|
||||
@font-face {
|
||||
font-family: "Maple Mono";
|
||||
src: url("/fonts/maple-mono-500-italic.woff2") format("woff2");
|
||||
font-weight: 500;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Maple Mono - SemiBold 600 */
|
||||
@font-face {
|
||||
font-family: "Maple Mono";
|
||||
src: url("/fonts/maple-mono-600-normal.woff2") format("woff2");
|
||||
font-weight: 600;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Maple Mono - SemiBold Italic 600 */
|
||||
@font-face {
|
||||
font-family: "Maple Mono";
|
||||
src: url("/fonts/maple-mono-600-italic.woff2") format("woff2");
|
||||
font-weight: 600;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Maple Mono - Bold 700 */
|
||||
@font-face {
|
||||
font-family: "Maple Mono";
|
||||
src: url("/fonts/maple-mono-700-normal.woff2") format("woff2");
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Maple Mono - Bold Italic 700 */
|
||||
@font-face {
|
||||
font-family: "Maple Mono";
|
||||
src: url("/fonts/maple-mono-700-italic.woff2") format("woff2");
|
||||
font-weight: 700;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Maple Mono - ExtraBold 800 */
|
||||
@font-face {
|
||||
font-family: "Maple Mono";
|
||||
src: url("/fonts/maple-mono-800-normal.woff2") format("woff2");
|
||||
font-weight: 800;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Maple Mono - ExtraBold Italic 800 */
|
||||
@font-face {
|
||||
font-family: "Maple Mono";
|
||||
src: url("/fonts/maple-mono-800-italic.woff2") format("woff2");
|
||||
font-weight: 800;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
--primary-color-hover: rgb(9, 95, 105);
|
||||
--primary-color: rgb(10, 110, 120);
|
||||
--primary-color-disabled: rgba(93, 152, 156, 0.5);
|
||||
--new-post-icon-color: rgba(10, 111, 120, 0.598);
|
||||
--header-height: 60px;
|
||||
--header-background-color: white;
|
||||
--header-border-color: lightgray;
|
||||
@@ -15,14 +16,16 @@
|
||||
--menu-selected-background-color: rgba(208, 250, 255, 0.659);
|
||||
--menu-text-color: black;
|
||||
--scroller-background-color: rgba(130, 175, 180, 0.5);
|
||||
--normal-background-color: rgb(241, 241, 241);
|
||||
/* --normal-background-color: rgb(241, 241, 241); */
|
||||
--normal-background-color: white;
|
||||
--lottery-background-color: rgb(241, 241, 241);
|
||||
--code-highlight-background-color: rgb(241, 241, 241);
|
||||
--login-background-color: rgb(248, 248, 248);
|
||||
--login-background-color-hover: #e0e0e0;
|
||||
--text-color: black;
|
||||
--blockquote-text-color: #6a737d;
|
||||
--menu-width: 200px;
|
||||
--page-max-width: 1200px;
|
||||
--page-max-width: 1400px;
|
||||
--page-max-width-mobile: 900px;
|
||||
--article-info-background-color: #f0f0f0;
|
||||
--activity-card-background-color: #fafafa;
|
||||
@@ -40,10 +43,13 @@
|
||||
--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;
|
||||
/* --normal-background-color: #000000; */
|
||||
--normal-background-color: #333;
|
||||
--lottery-background-color: #4e4e4e;
|
||||
--code-highlight-background-color: #262b35;
|
||||
--login-background-color: #575757;
|
||||
--login-background-color-hover: #717171;
|
||||
--text-color: #eee;
|
||||
@@ -131,13 +137,43 @@ body {
|
||||
}
|
||||
|
||||
.info-content-text pre {
|
||||
background-color: var(--normal-background-color);
|
||||
display: flex;
|
||||
background-color: var(--code-highlight-background-color);
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
line-height: 1.5;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.info-content-text pre .line-numbers {
|
||||
counter-reset: line-number 0;
|
||||
width: 2em;
|
||||
font-size: 13px;
|
||||
position: sticky;
|
||||
flex-shrink: 0;
|
||||
font-family: 'Maple Mono', monospace;
|
||||
margin: 1em 0;
|
||||
color: #888;
|
||||
border-right: 1px solid #888;
|
||||
box-sizing: border-box;
|
||||
padding-right: 0.5em;
|
||||
text-align: end;
|
||||
}
|
||||
|
||||
.info-content-text pre .line-numbers .line-number::before {
|
||||
content: counter(line-number);
|
||||
counter-increment: line-number;
|
||||
}
|
||||
|
||||
.info-content-text code {
|
||||
font-family: 'Maple Mono', monospace;
|
||||
font-size: 13px;
|
||||
border-radius: 4px;
|
||||
white-space: no-wrap;
|
||||
background-color: var(--code-highlight-background-color);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.copy-code-btn {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
@@ -156,20 +192,13 @@ body {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.info-content-text code {
|
||||
font-family: 'Roboto Mono', monospace;
|
||||
font-size: 13px;
|
||||
border-radius: 4px;
|
||||
white-space: pre-wrap;
|
||||
background-color: var(--normal-background-color);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.about-content a,
|
||||
.info-content-text a {
|
||||
color: var(--primary-color);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.about-content a:hover,
|
||||
.info-content-text a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
@@ -267,7 +296,7 @@ body {
|
||||
}
|
||||
|
||||
.info-content-text pre {
|
||||
line-height: 1.1;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.vditor-panel {
|
||||
|
||||
@@ -82,6 +82,7 @@ export default {
|
||||
.dropdown-item {
|
||||
padding: 8px 16px;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
.dropdown-item:hover {
|
||||
background-color: var(--menu-selected-background-color);
|
||||
|
||||
@@ -24,6 +24,13 @@
|
||||
<div v-if="isMobile" class="search-icon" @click="search">
|
||||
<i class="fas fa-search"></i>
|
||||
</div>
|
||||
|
||||
<ToolTip v-if="!isMobile" content="发帖" placement="bottom">
|
||||
<div class="new-post-icon" @click="goToNewPost">
|
||||
<i class="fas fa-edit"></i>
|
||||
</div>
|
||||
</ToolTip>
|
||||
|
||||
<DropdownMenu ref="userMenu" :items="headerMenuItems">
|
||||
<template #trigger>
|
||||
<div class="avatar-container">
|
||||
@@ -52,6 +59,7 @@
|
||||
import { ClientOnly } from '#components'
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import DropdownMenu from '~/components/DropdownMenu.vue'
|
||||
import ToolTip from '~/components/ToolTip.vue'
|
||||
import SearchDropdown from '~/components/SearchDropdown.vue'
|
||||
import { authState, clearToken, loadCurrentUser } from '~/utils/auth'
|
||||
import { fetchUnreadCount, notificationState } from '~/utils/notification'
|
||||
@@ -113,6 +121,10 @@ const goToLogout = () => {
|
||||
navigateTo('/login', { replace: true })
|
||||
}
|
||||
|
||||
const goToNewPost = () => {
|
||||
navigateTo('/new-post', { replace: false })
|
||||
}
|
||||
|
||||
const headerMenuItems = computed(() => [
|
||||
{ text: '设置', onClick: goToSettings },
|
||||
{ text: '个人主页', onClick: goToProfile },
|
||||
@@ -146,14 +158,6 @@ onMounted(async () => {
|
||||
await updateUnread()
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => router.currentRoute.value.fullPath,
|
||||
() => {
|
||||
if (userMenu.value) userMenu.value.close()
|
||||
showSearch.value = false
|
||||
},
|
||||
)
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -283,6 +287,12 @@ onMounted(async () => {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.new-post-icon {
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.header-content {
|
||||
padding-left: 15px;
|
||||
|
||||
@@ -1,119 +1,120 @@
|
||||
<template>
|
||||
<transition name="slide">
|
||||
<nav v-if="visible" class="menu">
|
||||
<div class="menu-item-container">
|
||||
<NuxtLink class="menu-item" exact-active-class="selected" to="/" @click="handleItemClick">
|
||||
<i class="menu-item-icon fas fa-hashtag"></i>
|
||||
<span class="menu-item-text">话题</span>
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
class="menu-item"
|
||||
exact-active-class="selected"
|
||||
to="/message"
|
||||
@click="handleItemClick"
|
||||
>
|
||||
<i class="menu-item-icon fas fa-envelope"></i>
|
||||
<span class="menu-item-text">我的消息</span>
|
||||
<span v-if="unreadCount > 0" class="unread-container">
|
||||
<span class="unread"> {{ showUnreadCount }} </span>
|
||||
</span>
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
class="menu-item"
|
||||
exact-active-class="selected"
|
||||
to="/about"
|
||||
@click="handleItemClick"
|
||||
>
|
||||
<i class="menu-item-icon fas fa-info-circle"></i>
|
||||
<span class="menu-item-text">关于</span>
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
class="menu-item"
|
||||
exact-active-class="selected"
|
||||
to="/activities"
|
||||
@click="handleItemClick"
|
||||
>
|
||||
<i class="menu-item-icon fas fa-gift"></i>
|
||||
<span class="menu-item-text">🔥 活动</span>
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
v-if="shouldShowStats"
|
||||
class="menu-item"
|
||||
exact-active-class="selected"
|
||||
to="/about/stats"
|
||||
@click="handleItemClick"
|
||||
>
|
||||
<i class="menu-item-icon fas fa-chart-line"></i>
|
||||
<span class="menu-item-text">站点统计</span>
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
class="menu-item"
|
||||
exact-active-class="selected"
|
||||
to="/new-post"
|
||||
@click="handleItemClick"
|
||||
>
|
||||
<i class="menu-item-icon fas fa-edit"></i>
|
||||
<span class="menu-item-text">发帖</span>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<div class="menu-section">
|
||||
<div class="section-header" @click="categoryOpen = !categoryOpen">
|
||||
<span>类别</span>
|
||||
<i :class="categoryOpen ? 'fas fa-chevron-up' : 'fas fa-chevron-down'"></i>
|
||||
</div>
|
||||
<div v-if="categoryOpen" class="section-items">
|
||||
<div v-if="isLoadingCategory" class="menu-loading-container">
|
||||
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
v-for="c in categoryData"
|
||||
:key="c.id"
|
||||
class="section-item"
|
||||
@click="gotoCategory(c)"
|
||||
<div class="menu-content">
|
||||
<div class="menu-item-container">
|
||||
<NuxtLink class="menu-item" exact-active-class="selected" to="/" @click="handleItemClick">
|
||||
<i class="menu-item-icon fas fa-hashtag"></i>
|
||||
<span class="menu-item-text">话题</span>
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
class="menu-item"
|
||||
exact-active-class="selected"
|
||||
to="/new-post"
|
||||
@click="handleItemClick"
|
||||
>
|
||||
<template v-if="c.smallIcon || c.icon">
|
||||
<img
|
||||
v-if="isImageIcon(c.smallIcon || c.icon)"
|
||||
:src="c.smallIcon || c.icon"
|
||||
class="section-item-icon"
|
||||
:alt="c.name"
|
||||
/>
|
||||
<i v-else :class="['section-item-icon', c.smallIcon || c.icon]"></i>
|
||||
</template>
|
||||
<span class="section-item-text">
|
||||
{{ c.name }}
|
||||
<span class="section-item-text-count" v-if="c.count >= 0">x {{ c.count }}</span>
|
||||
<i class="menu-item-icon fas fa-edit"></i>
|
||||
<span class="menu-item-text">发帖</span>
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
class="menu-item"
|
||||
exact-active-class="selected"
|
||||
to="/message"
|
||||
@click="handleItemClick"
|
||||
>
|
||||
<i class="menu-item-icon fas fa-envelope"></i>
|
||||
<span class="menu-item-text">我的消息</span>
|
||||
<span v-if="unreadCount > 0" class="unread-container">
|
||||
<span class="unread"> {{ showUnreadCount }} </span>
|
||||
</span>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
class="menu-item"
|
||||
exact-active-class="selected"
|
||||
to="/about"
|
||||
@click="handleItemClick"
|
||||
>
|
||||
<i class="menu-item-icon fas fa-info-circle"></i>
|
||||
<span class="menu-item-text">关于</span>
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
class="menu-item"
|
||||
exact-active-class="selected"
|
||||
to="/activities"
|
||||
@click="handleItemClick"
|
||||
>
|
||||
<i class="menu-item-icon fas fa-gift"></i>
|
||||
<span class="menu-item-text">🔥 活动</span>
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
v-if="shouldShowStats"
|
||||
class="menu-item"
|
||||
exact-active-class="selected"
|
||||
to="/about/stats"
|
||||
@click="handleItemClick"
|
||||
>
|
||||
<i class="menu-item-icon fas fa-chart-line"></i>
|
||||
<span class="menu-item-text">站点统计</span>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="menu-section">
|
||||
<div class="section-header" @click="tagOpen = !tagOpen">
|
||||
<span>tag</span>
|
||||
<i :class="tagOpen ? 'fas fa-chevron-up' : 'fas fa-chevron-down'"></i>
|
||||
</div>
|
||||
<div v-if="tagOpen" class="section-items">
|
||||
<div v-if="isLoadingTag" class="menu-loading-container">
|
||||
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
|
||||
<div class="menu-section">
|
||||
<div class="section-header" @click="categoryOpen = !categoryOpen">
|
||||
<span>类别</span>
|
||||
<i :class="categoryOpen ? 'fas fa-chevron-up' : 'fas fa-chevron-down'"></i>
|
||||
</div>
|
||||
<div v-else v-for="t in tagData" :key="t.id" class="section-item" @click="gotoTag(t)">
|
||||
<img
|
||||
v-if="isImageIcon(t.smallIcon || t.icon)"
|
||||
:src="t.smallIcon || t.icon"
|
||||
class="section-item-icon"
|
||||
:alt="t.name"
|
||||
/>
|
||||
<i v-else class="section-item-icon fas fa-hashtag"></i>
|
||||
<span class="section-item-text"
|
||||
>{{ t.name }} <span class="section-item-text-count">x {{ t.count }}</span></span
|
||||
<div v-if="categoryOpen" class="section-items">
|
||||
<div v-if="isLoadingCategory" class="menu-loading-container">
|
||||
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
v-for="c in categoryData"
|
||||
:key="c.id"
|
||||
class="section-item"
|
||||
@click="gotoCategory(c)"
|
||||
>
|
||||
<template v-if="c.smallIcon || c.icon">
|
||||
<img
|
||||
v-if="isImageIcon(c.smallIcon || c.icon)"
|
||||
:src="c.smallIcon || c.icon"
|
||||
class="section-item-icon"
|
||||
:alt="c.name"
|
||||
/>
|
||||
<i v-else :class="['section-item-icon', c.smallIcon || c.icon]"></i>
|
||||
</template>
|
||||
<span class="section-item-text">
|
||||
{{ c.name }}
|
||||
<span class="section-item-text-count" v-if="c.count >= 0">x {{ c.count }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="menu-section">
|
||||
<div class="section-header" @click="tagOpen = !tagOpen">
|
||||
<span>tag</span>
|
||||
<i :class="tagOpen ? 'fas fa-chevron-up' : 'fas fa-chevron-down'"></i>
|
||||
</div>
|
||||
<div v-if="tagOpen" class="section-items">
|
||||
<div v-if="isLoadingTag" class="menu-loading-container">
|
||||
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
|
||||
</div>
|
||||
<div v-else v-for="t in tagData" :key="t.id" class="section-item" @click="gotoTag(t)">
|
||||
<img
|
||||
v-if="isImageIcon(t.smallIcon || t.icon)"
|
||||
:src="t.smallIcon || t.icon"
|
||||
class="section-item-icon"
|
||||
:alt="t.name"
|
||||
/>
|
||||
<i v-else class="section-item-icon fas fa-hashtag"></i>
|
||||
<span class="section-item-text"
|
||||
>{{ t.name }} <span class="section-item-text-count">x {{ t.count }}</span></span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="menu-footer">
|
||||
<div class="menu-footer-btn" @click="cycleTheme">
|
||||
<i :class="iconClass"></i>
|
||||
@@ -128,41 +129,46 @@ import { ref, computed, watch, onMounted } from 'vue'
|
||||
import { themeState, cycleTheme, ThemeMode } from '~/utils/theme'
|
||||
import { authState } from '~/utils/auth'
|
||||
import { fetchUnreadCount, notificationState } from '~/utils/notification'
|
||||
|
||||
const config = useRuntimeConfig()
|
||||
const API_BASE_URL = config.public.apiBaseUrl
|
||||
|
||||
const props = defineProps({
|
||||
visible: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
visible: { type: Boolean, default: true },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['item-click'])
|
||||
|
||||
const categoryOpen = ref(true)
|
||||
const tagOpen = ref(true)
|
||||
const isLoadingCategory = ref(false)
|
||||
const isLoadingTag = ref(false)
|
||||
const categoryData = ref([])
|
||||
const tagData = ref([])
|
||||
|
||||
const fetchCategoryData = async () => {
|
||||
isLoadingCategory.value = true
|
||||
const res = await fetch(`${API_BASE_URL}/api/categories`)
|
||||
const data = await res.json()
|
||||
categoryData.value = data
|
||||
isLoadingCategory.value = false
|
||||
}
|
||||
/** ✅ 用 useAsyncData 替换原生 fetch,避免 SSR+CSR 二次请求 */
|
||||
const {
|
||||
data: categoryData,
|
||||
pending: isLoadingCategory,
|
||||
error: categoryError,
|
||||
} = await useAsyncData(
|
||||
// 稳定 key:避免 hydration 期误判
|
||||
'menu:categories',
|
||||
() => $fetch(`${API_BASE_URL}/api/categories`),
|
||||
{
|
||||
server: true, // SSR 预取
|
||||
default: () => [], // 初始默认值,减少空判断
|
||||
// 5 分钟内复用缓存,避免路由往返重复请求
|
||||
staleTime: 5 * 60 * 1000,
|
||||
},
|
||||
)
|
||||
|
||||
const fetchTagData = async () => {
|
||||
isLoadingTag.value = true
|
||||
const res = await fetch(`${API_BASE_URL}/api/tags?limit=10`)
|
||||
const data = await res.json()
|
||||
tagData.value = data
|
||||
isLoadingTag.value = false
|
||||
}
|
||||
const {
|
||||
data: tagData,
|
||||
pending: isLoadingTag,
|
||||
error: tagError,
|
||||
} = await useAsyncData('menu:tags', () => $fetch(`${API_BASE_URL}/api/tags?limit=10`), {
|
||||
server: true,
|
||||
default: () => [],
|
||||
staleTime: 5 * 60 * 1000,
|
||||
})
|
||||
|
||||
/** 其余逻辑保持不变 */
|
||||
const iconClass = computed(() => {
|
||||
switch (themeState.mode) {
|
||||
case ThemeMode.DARK:
|
||||
@@ -188,6 +194,7 @@ const updateCount = async () => {
|
||||
|
||||
onMounted(async () => {
|
||||
await updateCount()
|
||||
// 登录态变化时再拉一次未读数;与 useAsyncData 无关
|
||||
watch(() => authState.loggedIn, updateCount)
|
||||
})
|
||||
|
||||
@@ -211,28 +218,34 @@ const gotoTag = (t) => {
|
||||
navigateTo({ path: '/', query: { tags: value } }, { replace: true })
|
||||
handleItemClick()
|
||||
}
|
||||
|
||||
await Promise.all([fetchCategoryData(), fetchTagData()])
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.menu {
|
||||
position: sticky;
|
||||
top: var(--header-height);
|
||||
width: 200px;
|
||||
width: 220px;
|
||||
background-color: var(--menu-background-color);
|
||||
height: calc(100vh - 20px - var(--header-height));
|
||||
border-right: 1px solid var(--menu-border-color);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 10px;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.menu-item-container {
|
||||
.menu-content {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
box-sizing: border-box;
|
||||
padding: 10px 10px 0 10px;
|
||||
}
|
||||
|
||||
/* .menu-item-container { */
|
||||
/**/
|
||||
/* } */
|
||||
|
||||
.menu-item {
|
||||
padding: 4px 10px;
|
||||
text-decoration: none;
|
||||
@@ -278,10 +291,8 @@ await Promise.all([fetchCategoryData(), fetchTagData()])
|
||||
}
|
||||
|
||||
.menu-footer {
|
||||
position: fixed;
|
||||
position: relation;
|
||||
height: 30px;
|
||||
bottom: 10px;
|
||||
right: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
@@ -369,6 +380,10 @@ await Promise.all([fetchCategoryData(), fetchTagData()])
|
||||
background-color: var(--background-color-blur);
|
||||
}
|
||||
|
||||
.menu-content {
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.slide-enter-active,
|
||||
.slide-leave-active {
|
||||
transition:
|
||||
|
||||
@@ -110,6 +110,10 @@ watch(selected, (val) => {
|
||||
selected.value = null
|
||||
keyword.value = ''
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
toggle,
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
488
frontend_nuxt/components/ToolTip.vue
Normal file
488
frontend_nuxt/components/ToolTip.vue
Normal file
@@ -0,0 +1,488 @@
|
||||
<template>
|
||||
<div class="tooltip-wrapper" ref="wrapperRef">
|
||||
<!-- 触发器 -->
|
||||
<div
|
||||
class="tooltip-trigger"
|
||||
@mouseenter="handleMouseEnter"
|
||||
@mouseleave="handleMouseLeave"
|
||||
@click="handleClick"
|
||||
@focus="handleFocus"
|
||||
@blur="handleBlur"
|
||||
:tabindex="focusable ? 0 : -1"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<!-- 提示内容 -->
|
||||
<Transition name="tooltip-fade">
|
||||
<div
|
||||
v-if="visible"
|
||||
ref="tooltipRef"
|
||||
class="tooltip-content"
|
||||
:class="[
|
||||
`tooltip-${placement}`,
|
||||
{ 'tooltip-dark': dark },
|
||||
{ 'tooltip-light': !dark }
|
||||
]"
|
||||
:style="tooltipStyle"
|
||||
role="tooltip"
|
||||
:aria-describedby="ariaId"
|
||||
>
|
||||
<div class="tooltip-inner">
|
||||
<slot name="content">
|
||||
{{ content }}
|
||||
</slot>
|
||||
</div>
|
||||
<div class="tooltip-arrow" :class="`tooltip-arrow-${placement}`"></div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref, computed, onMounted, onBeforeUnmount, nextTick, useId, watch } from 'vue'
|
||||
|
||||
export default {
|
||||
name: 'ToolTip',
|
||||
props: {
|
||||
// 提示内容
|
||||
content: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
// 触发方式:hover、click、focus
|
||||
trigger: {
|
||||
type: String,
|
||||
default: 'hover',
|
||||
validator: (value) => ['hover', 'click', 'focus', 'manual'].includes(value)
|
||||
},
|
||||
// 位置:top、bottom、left、right
|
||||
placement: {
|
||||
type: String,
|
||||
default: 'top',
|
||||
validator: (value) => ['top', 'bottom', 'left', 'right'].includes(value)
|
||||
},
|
||||
// 是否启用暗色主题
|
||||
dark: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
// 延迟显示时间(毫秒)
|
||||
delay: {
|
||||
type: Number,
|
||||
default: 100
|
||||
},
|
||||
// 是否禁用
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
// 是否可通过Tab键聚焦
|
||||
focusable: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
// 偏移距离
|
||||
offset: {
|
||||
type: Number,
|
||||
default: 8
|
||||
},
|
||||
// 最大宽度
|
||||
maxWidth: {
|
||||
type: [String, Number],
|
||||
default: '200px'
|
||||
}
|
||||
},
|
||||
emits: ['show', 'hide'],
|
||||
setup(props, { emit }) {
|
||||
const wrapperRef = ref(null)
|
||||
const tooltipRef = ref(null)
|
||||
const visible = ref(false)
|
||||
const ariaId = ref(`tooltip-${useId()}`)
|
||||
|
||||
let showTimer = null
|
||||
let hideTimer = null
|
||||
|
||||
// 计算tooltip样式
|
||||
const tooltipStyle = computed(() => {
|
||||
const maxWidth = typeof props.maxWidth === 'number'
|
||||
? `${props.maxWidth}px`
|
||||
: props.maxWidth
|
||||
|
||||
return {
|
||||
maxWidth,
|
||||
zIndex: 2000
|
||||
}
|
||||
})
|
||||
|
||||
// 显示tooltip
|
||||
const show = () => {
|
||||
if (props.disabled) return
|
||||
|
||||
clearTimeout(hideTimer)
|
||||
showTimer = setTimeout(() => {
|
||||
visible.value = true
|
||||
emit('show')
|
||||
nextTick(() => {
|
||||
updatePosition()
|
||||
})
|
||||
}, props.delay)
|
||||
}
|
||||
|
||||
// 隐藏tooltip
|
||||
const hide = () => {
|
||||
clearTimeout(showTimer)
|
||||
hideTimer = setTimeout(() => {
|
||||
visible.value = false
|
||||
emit('hide')
|
||||
}, 100)
|
||||
}
|
||||
|
||||
// 立即显示(用于manual模式)
|
||||
const showImmediately = () => {
|
||||
if (props.disabled) return
|
||||
clearTimeout(hideTimer)
|
||||
clearTimeout(showTimer)
|
||||
visible.value = true
|
||||
emit('show')
|
||||
nextTick(() => {
|
||||
updatePosition()
|
||||
})
|
||||
}
|
||||
|
||||
// 立即隐藏(用于manual模式)
|
||||
const hideImmediately = () => {
|
||||
clearTimeout(showTimer)
|
||||
clearTimeout(hideTimer)
|
||||
visible.value = false
|
||||
emit('hide')
|
||||
}
|
||||
|
||||
// 更新位置
|
||||
const updatePosition = () => {
|
||||
if (!wrapperRef.value || !tooltipRef.value) return
|
||||
|
||||
const trigger = wrapperRef.value.querySelector('.tooltip-trigger')
|
||||
const tooltip = tooltipRef.value
|
||||
|
||||
if (!trigger) return
|
||||
|
||||
const triggerRect = trigger.getBoundingClientRect()
|
||||
const tooltipRect = tooltip.getBoundingClientRect()
|
||||
|
||||
let top = 0
|
||||
let left = 0
|
||||
|
||||
switch (props.placement) {
|
||||
case 'top':
|
||||
top = triggerRect.top - tooltipRect.height - props.offset
|
||||
left = triggerRect.left + (triggerRect.width - tooltipRect.width) / 2
|
||||
break
|
||||
case 'bottom':
|
||||
top = triggerRect.bottom + props.offset
|
||||
left = triggerRect.left + (triggerRect.width - tooltipRect.width) / 2
|
||||
break
|
||||
case 'left':
|
||||
top = triggerRect.top + (triggerRect.height - tooltipRect.height) / 2
|
||||
left = triggerRect.left - tooltipRect.width - props.offset
|
||||
break
|
||||
case 'right':
|
||||
top = triggerRect.top + (triggerRect.height - tooltipRect.height) / 2
|
||||
left = triggerRect.right + props.offset
|
||||
break
|
||||
}
|
||||
|
||||
// 边界检测
|
||||
const padding = 8
|
||||
const viewportWidth = window.innerWidth
|
||||
const viewportHeight = window.innerHeight
|
||||
|
||||
if (left < padding) {
|
||||
left = padding
|
||||
} else if (left + tooltipRect.width > viewportWidth - padding) {
|
||||
left = viewportWidth - tooltipRect.width - padding
|
||||
}
|
||||
|
||||
if (top < padding) {
|
||||
top = padding
|
||||
} else if (top + tooltipRect.height > viewportHeight - padding) {
|
||||
top = viewportHeight - tooltipRect.height - padding
|
||||
}
|
||||
|
||||
tooltip.style.position = 'fixed'
|
||||
tooltip.style.top = `${top}px`
|
||||
tooltip.style.left = `${left}px`
|
||||
}
|
||||
|
||||
// 事件处理
|
||||
const handleMouseEnter = () => {
|
||||
if (props.trigger === 'hover') {
|
||||
show()
|
||||
}
|
||||
}
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
if (props.trigger === 'hover') {
|
||||
hide()
|
||||
}
|
||||
}
|
||||
|
||||
const handleClick = () => {
|
||||
if (props.trigger === 'click') {
|
||||
if (visible.value) {
|
||||
hide()
|
||||
} else {
|
||||
show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleFocus = () => {
|
||||
if (props.trigger === 'focus') {
|
||||
show()
|
||||
}
|
||||
}
|
||||
|
||||
const handleBlur = () => {
|
||||
if (props.trigger === 'focus') {
|
||||
hide()
|
||||
}
|
||||
}
|
||||
|
||||
// 点击外部隐藏
|
||||
const handleClickOutside = (event) => {
|
||||
if (props.trigger === 'click' && wrapperRef.value && !wrapperRef.value.contains(event.target)) {
|
||||
hide()
|
||||
}
|
||||
}
|
||||
|
||||
// 窗口大小改变时重新计算位置
|
||||
const handleResize = () => {
|
||||
if (visible.value) {
|
||||
updatePosition()
|
||||
}
|
||||
}
|
||||
|
||||
// 监听禁用状态变化
|
||||
watch(() => props.disabled, (newVal) => {
|
||||
if (newVal && visible.value) {
|
||||
hideImmediately()
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
window.addEventListener('resize', handleResize)
|
||||
window.addEventListener('scroll', handleResize)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearTimeout(showTimer)
|
||||
clearTimeout(hideTimer)
|
||||
document.removeEventListener('click', handleClickOutside)
|
||||
window.removeEventListener('resize', handleResize)
|
||||
window.removeEventListener('scroll', handleResize)
|
||||
})
|
||||
|
||||
return {
|
||||
wrapperRef,
|
||||
tooltipRef,
|
||||
visible,
|
||||
ariaId,
|
||||
tooltipStyle,
|
||||
handleMouseEnter,
|
||||
handleMouseLeave,
|
||||
handleClick,
|
||||
handleFocus,
|
||||
handleBlur,
|
||||
// 暴露给父组件的方法
|
||||
show: showImmediately,
|
||||
hide: hideImmediately
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tooltip-wrapper {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.tooltip-trigger {
|
||||
display: inline-block;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.tooltip-content {
|
||||
position: fixed;
|
||||
pointer-events: none;
|
||||
z-index: 2000;
|
||||
}
|
||||
|
||||
.tooltip-inner {
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
word-wrap: break-word;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
/* 亮色主题 */
|
||||
.tooltip-light .tooltip-inner {
|
||||
background-color: var(--background-color);
|
||||
color: var(--text-color);
|
||||
border: 1px solid var(--normal-border-color);
|
||||
}
|
||||
|
||||
/* 暗色主题 */
|
||||
.tooltip-dark .tooltip-inner {
|
||||
background-color: rgba(0, 0, 0, 0.9);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* 箭头基础样式 */
|
||||
.tooltip-arrow {
|
||||
position: absolute;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-style: solid;
|
||||
}
|
||||
|
||||
/* 顶部箭头 */
|
||||
.tooltip-top .tooltip-arrow-top {
|
||||
bottom: -6px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
border-width: 6px 6px 0 6px;
|
||||
}
|
||||
|
||||
.tooltip-light.tooltip-top .tooltip-arrow-top {
|
||||
border-color: var(--normal-border-color) transparent transparent transparent;
|
||||
}
|
||||
|
||||
.tooltip-light.tooltip-top .tooltip-arrow-top::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -7px;
|
||||
left: -6px;
|
||||
border-width: 6px 6px 0 6px;
|
||||
border-style: solid;
|
||||
border-color: var(--background-color) transparent transparent transparent;
|
||||
}
|
||||
|
||||
.tooltip-dark.tooltip-top .tooltip-arrow-top {
|
||||
border-color: rgba(0, 0, 0, 0.9) transparent transparent transparent;
|
||||
}
|
||||
|
||||
/* 底部箭头 */
|
||||
.tooltip-bottom .tooltip-arrow-bottom {
|
||||
top: -6px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
border-width: 0 6px 6px 6px;
|
||||
}
|
||||
|
||||
.tooltip-light.tooltip-bottom .tooltip-arrow-bottom {
|
||||
border-color: transparent transparent var(--normal-border-color) transparent;
|
||||
}
|
||||
|
||||
.tooltip-light.tooltip-bottom .tooltip-arrow-bottom::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 1px;
|
||||
left: -6px;
|
||||
border-width: 0 6px 6px 6px;
|
||||
border-style: solid;
|
||||
border-color: transparent transparent var(--background-color) transparent;
|
||||
}
|
||||
|
||||
.tooltip-dark.tooltip-bottom .tooltip-arrow-bottom {
|
||||
border-color: transparent transparent rgba(0, 0, 0, 0.9) transparent;
|
||||
}
|
||||
|
||||
/* 左侧箭头 */
|
||||
.tooltip-left .tooltip-arrow-left {
|
||||
right: -6px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
border-width: 6px 0 6px 6px;
|
||||
}
|
||||
|
||||
.tooltip-light.tooltip-left .tooltip-arrow-left {
|
||||
border-color: transparent transparent transparent var(--normal-border-color);
|
||||
}
|
||||
|
||||
.tooltip-light.tooltip-left .tooltip-arrow-left::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -6px;
|
||||
left: -7px;
|
||||
border-width: 6px 0 6px 6px;
|
||||
border-style: solid;
|
||||
border-color: transparent transparent transparent var(--background-color);
|
||||
}
|
||||
|
||||
.tooltip-dark.tooltip-left .tooltip-arrow-left {
|
||||
border-color: transparent transparent transparent rgba(0, 0, 0, 0.9);
|
||||
}
|
||||
|
||||
/* 右侧箭头 */
|
||||
.tooltip-right .tooltip-arrow-right {
|
||||
left: -6px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
border-width: 6px 6px 6px 0;
|
||||
}
|
||||
|
||||
.tooltip-light.tooltip-right .tooltip-arrow-right {
|
||||
border-color: transparent var(--normal-border-color) transparent transparent;
|
||||
}
|
||||
|
||||
.tooltip-light.tooltip-right .tooltip-arrow-right::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -6px;
|
||||
left: 1px;
|
||||
border-width: 6px 6px 6px 0;
|
||||
border-style: solid;
|
||||
border-color: transparent var(--background-color) transparent transparent;
|
||||
}
|
||||
|
||||
.tooltip-dark.tooltip-right .tooltip-arrow-right {
|
||||
border-color: transparent rgba(0, 0, 0, 0.9) transparent transparent;
|
||||
}
|
||||
|
||||
/* 过渡动画 */
|
||||
.tooltip-fade-enter-active,
|
||||
.tooltip-fade-leave-active {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.tooltip-fade-enter-from {
|
||||
opacity: 0;
|
||||
transform: scale(0.8);
|
||||
}
|
||||
|
||||
.tooltip-fade-leave-to {
|
||||
opacity: 0;
|
||||
transform: scale(0.8);
|
||||
}
|
||||
|
||||
/* 响应式调整 */
|
||||
@media (max-width: 768px) {
|
||||
.tooltip-inner {
|
||||
padding: 6px 10px;
|
||||
font-size: 13px;
|
||||
max-width: 250px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 键盘导航样式 */
|
||||
.tooltip-trigger:focus-visible {
|
||||
outline: 2px solid var(--primary-color);
|
||||
outline-offset: 2px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
</style>
|
||||
@@ -12,8 +12,8 @@ export default defineNuxtConfig({
|
||||
twitterClientId: process.env.NUXT_PUBLIC_TWITTER_CLIENT_ID || '',
|
||||
},
|
||||
},
|
||||
// Ensure Vditor styles load before our overrides in global.css
|
||||
css: ['vditor/dist/index.css', '~/assets/global.css'],
|
||||
// 确保 Vditor 样式在 global.css 覆盖前加载
|
||||
css: ['vditor/dist/index.css', '~/assets/fonts.css', '~/assets/global.css'],
|
||||
app: {
|
||||
head: {
|
||||
script: [
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
<div class="forgot-page">
|
||||
<div class="forgot-content">
|
||||
<div class="forgot-title">找回密码</div>
|
||||
|
||||
<div v-if="step === 0" class="step-content">
|
||||
<BaseInput icon="fas fa-envelope" v-model="email" placeholder="邮箱" />
|
||||
<div v-if="emailError" class="error-message">{{ emailError }}</div>
|
||||
@@ -19,6 +20,10 @@
|
||||
<div class="primary-button" @click="resetPassword" v-if="!isResetting">重置密码</div>
|
||||
<div class="primary-button disabled" v-else>提交中...</div>
|
||||
</div>
|
||||
<div class="hint-message">
|
||||
<i class="fas fa-info-circle"></i>
|
||||
使用 Google 注册的用户可使用对应的邮箱进行找回密码
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -26,6 +31,8 @@
|
||||
<script setup>
|
||||
import { toast } from '~/main'
|
||||
import BaseInput from '~/components/BaseInput.vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
const config = useRuntimeConfig()
|
||||
const API_BASE_URL = config.public.apiBaseUrl
|
||||
|
||||
@@ -39,6 +46,7 @@ const passwordError = ref('')
|
||||
const isSending = ref(false)
|
||||
const isVerifying = ref(false)
|
||||
const isResetting = ref(false)
|
||||
const route = useRoute()
|
||||
|
||||
onMounted(() => {
|
||||
if (route.query.email) {
|
||||
@@ -137,6 +145,21 @@ const resetPassword = async () => {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.forgot-content .hint-message {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 0;
|
||||
font-size: 13px;
|
||||
color: var(--blockquote-text-color);
|
||||
}
|
||||
|
||||
.hint-message i {
|
||||
color: var(--primary-color);
|
||||
font-size: 14px;
|
||||
}
|
||||
.step-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
<template>
|
||||
<div class="home-page">
|
||||
<div v-if="!isMobile" class="search-container">
|
||||
<div class="search-title">一切可能,从此刻启航</div>
|
||||
<div class="search-subtitle">
|
||||
愿你在此遇见灵感与共鸣。若有疑惑,欢迎发问,亦可在知识的海洋中搜寻答案。
|
||||
</div>
|
||||
<div class="search-title">一切可能,从此刻启航,在此遇见灵感与共鸣</div>
|
||||
<SearchDropdown />
|
||||
</div>
|
||||
|
||||
@@ -116,7 +113,7 @@
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { ref, watch, watchEffect, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import ArticleCategory from '~/components/ArticleCategory.vue'
|
||||
import ArticleTags from '~/components/ArticleTags.vue'
|
||||
import CategorySelect from '~/components/CategorySelect.vue'
|
||||
@@ -195,7 +192,7 @@ watch(
|
||||
const loadOptions = async () => {
|
||||
if (selectedCategory.value && !isNaN(selectedCategory.value)) {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE_URL}/api/categories/${selectedCategory.value}`)
|
||||
const res = await fetch(`${API_BASE_URL}/api/categories/`)
|
||||
if (res.ok) categoryOptions.value = [await res.json()]
|
||||
} catch {}
|
||||
}
|
||||
@@ -371,8 +368,8 @@ const sanitizeDescription = (text) => stripMarkdown(text)
|
||||
}
|
||||
|
||||
.search-container {
|
||||
margin-top: 100px;
|
||||
padding: 20px;
|
||||
margin-top: 32px;
|
||||
padding: 20px 20px 32px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
@@ -384,9 +381,6 @@ const sanitizeDescription = (text) => stripMarkdown(text)
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.search-subtitle {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.loading-container {
|
||||
display: flex;
|
||||
|
||||
@@ -77,7 +77,7 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script setup>
|
||||
import 'flatpickr/dist/flatpickr.css'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import FlatPickr from 'vue-flatpickr-component'
|
||||
|
||||
@@ -232,7 +232,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onBeforeUnmount, nextTick, watch } from 'vue'
|
||||
import { ref, computed, onMounted, onBeforeUnmount, nextTick, watch, watchEffect } from 'vue'
|
||||
import VueEasyLightbox from 'vue-easy-lightbox'
|
||||
import { useRoute } from 'vue-router'
|
||||
import CommentItem from '~/components/CommentItem.vue'
|
||||
@@ -268,7 +268,6 @@ const postReactions = ref([])
|
||||
const comments = ref([])
|
||||
const status = ref('PUBLISHED')
|
||||
const pinnedAt = ref(null)
|
||||
const isWaitingFetchingPost = ref(false)
|
||||
const isWaitingPostingComment = ref(false)
|
||||
const postTime = ref('')
|
||||
const postItems = ref([])
|
||||
@@ -455,38 +454,41 @@ const onCommentDeleted = (id) => {
|
||||
fetchComments()
|
||||
}
|
||||
|
||||
const fetchPost = async () => {
|
||||
try {
|
||||
isWaitingFetchingPost.value = true
|
||||
const token = getToken()
|
||||
const res = await fetch(`${API_BASE_URL}/api/posts/${postId}`, {
|
||||
headers: { Authorization: token ? `Bearer ${token}` : '' },
|
||||
})
|
||||
isWaitingFetchingPost.value = false
|
||||
if (!res.ok) {
|
||||
if (res.status === 404 && process.client) {
|
||||
router.replace('/404')
|
||||
}
|
||||
return
|
||||
}
|
||||
const data = await res.json()
|
||||
postContent.value = data.content
|
||||
author.value = data.author
|
||||
title.value = data.title
|
||||
category.value = data.category
|
||||
tags.value = data.tags || []
|
||||
postReactions.value = data.reactions || []
|
||||
subscribed.value = !!data.subscribed
|
||||
status.value = data.status
|
||||
pinnedAt.value = data.pinnedAt
|
||||
postTime.value = TimeManager.format(data.createdAt)
|
||||
lottery.value = data.lottery || null
|
||||
if (lottery.value && lottery.value.endTime) startCountdown()
|
||||
await nextTick()
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
const {
|
||||
data: postData,
|
||||
pending: pendingPost,
|
||||
error: postError,
|
||||
refresh: refreshPost,
|
||||
} = await useAsyncData(`post-${postId}`, () => $fetch(`${API_BASE_URL}/api/posts/${postId}`), {
|
||||
server: true,
|
||||
lazy: false,
|
||||
})
|
||||
|
||||
// 用 pendingPost 驱动现有 UI(替代 isWaitingFetchingPost 手控)
|
||||
const isWaitingFetchingPost = computed(() => pendingPost.value)
|
||||
|
||||
// 同步到现有的响应式字段
|
||||
watchEffect(() => {
|
||||
const data = postData.value
|
||||
if (!data) return
|
||||
postContent.value = data.content
|
||||
author.value = data.author
|
||||
title.value = data.title
|
||||
category.value = data.category
|
||||
tags.value = data.tags || []
|
||||
postReactions.value = data.reactions || []
|
||||
subscribed.value = !!data.subscribed
|
||||
status.value = data.status
|
||||
pinnedAt.value = data.pinnedAt
|
||||
postTime.value = TimeManager.format(data.createdAt)
|
||||
lottery.value = data.lottery || null
|
||||
if (lottery.value && lottery.value.endTime) startCountdown()
|
||||
})
|
||||
|
||||
// 404 客户端跳转
|
||||
// if (postError.value?.statusCode === 404 && process.client) {
|
||||
// router.replace('/404')
|
||||
// }
|
||||
|
||||
const totalPosts = computed(() => comments.value.length + 1)
|
||||
const lastReplyTime = computed(() =>
|
||||
@@ -607,6 +609,7 @@ const approvePost = async () => {
|
||||
if (res.ok) {
|
||||
status.value = 'PUBLISHED'
|
||||
toast.success('已通过审核')
|
||||
await refreshPost()
|
||||
} else {
|
||||
toast.error('操作失败')
|
||||
}
|
||||
@@ -620,8 +623,8 @@ const pinPost = async () => {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
if (res.ok) {
|
||||
pinnedAt.value = new Date().toISOString()
|
||||
toast.success('已置顶')
|
||||
await refreshPost()
|
||||
} else {
|
||||
toast.error('操作失败')
|
||||
}
|
||||
@@ -635,8 +638,8 @@ const unpinPost = async () => {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
if (res.ok) {
|
||||
pinnedAt.value = null
|
||||
toast.success('已取消置顶')
|
||||
await refreshPost()
|
||||
} else {
|
||||
toast.error('操作失败')
|
||||
}
|
||||
@@ -674,6 +677,7 @@ const rejectPost = async () => {
|
||||
if (res.ok) {
|
||||
status.value = 'REJECTED'
|
||||
toast.success('已驳回')
|
||||
await refreshPost()
|
||||
} else {
|
||||
toast.error('操作失败')
|
||||
}
|
||||
@@ -709,7 +713,7 @@ const joinLottery = async () => {
|
||||
})
|
||||
if (res.ok) {
|
||||
toast.success('已参与抽奖')
|
||||
await fetchPost()
|
||||
await refreshPost()
|
||||
} else {
|
||||
toast.error('操作失败')
|
||||
}
|
||||
@@ -780,9 +784,8 @@ onMounted(async () => {
|
||||
window.addEventListener('scroll', updateCurrentIndex)
|
||||
jumpToHashComment()
|
||||
})
|
||||
|
||||
await fetchPost()
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.post-page-container {
|
||||
background-color: var(--background-color);
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
import { defineNuxtPlugin } from 'nuxt/app'
|
||||
|
||||
export default defineNuxtPlugin((nuxtApp) => {
|
||||
// 覆盖默认行为:收到 manifest 更新时,不立刻在路由切换里刷新
|
||||
nuxtApp.hooks.hook('app:manifest:update', () => {
|
||||
// todo 选择:弹个提示,让用户点击刷新;或延迟到页面隐藏时再刷新
|
||||
// 例如:document.addEventListener('visibilitychange', () => { if (document.hidden) location.reload() })
|
||||
})
|
||||
})
|
||||
BIN
frontend_nuxt/public/fonts/maple-mono-100-italic.woff2
Normal file
BIN
frontend_nuxt/public/fonts/maple-mono-100-italic.woff2
Normal file
Binary file not shown.
BIN
frontend_nuxt/public/fonts/maple-mono-100-normal.woff2
Normal file
BIN
frontend_nuxt/public/fonts/maple-mono-100-normal.woff2
Normal file
Binary file not shown.
BIN
frontend_nuxt/public/fonts/maple-mono-200-italic.woff2
Normal file
BIN
frontend_nuxt/public/fonts/maple-mono-200-italic.woff2
Normal file
Binary file not shown.
BIN
frontend_nuxt/public/fonts/maple-mono-200-normal.woff2
Normal file
BIN
frontend_nuxt/public/fonts/maple-mono-200-normal.woff2
Normal file
Binary file not shown.
BIN
frontend_nuxt/public/fonts/maple-mono-300-italic.woff2
Normal file
BIN
frontend_nuxt/public/fonts/maple-mono-300-italic.woff2
Normal file
Binary file not shown.
BIN
frontend_nuxt/public/fonts/maple-mono-300-normal.woff2
Normal file
BIN
frontend_nuxt/public/fonts/maple-mono-300-normal.woff2
Normal file
Binary file not shown.
BIN
frontend_nuxt/public/fonts/maple-mono-400-italic.woff2
Normal file
BIN
frontend_nuxt/public/fonts/maple-mono-400-italic.woff2
Normal file
Binary file not shown.
BIN
frontend_nuxt/public/fonts/maple-mono-400-normal.woff2
Normal file
BIN
frontend_nuxt/public/fonts/maple-mono-400-normal.woff2
Normal file
Binary file not shown.
BIN
frontend_nuxt/public/fonts/maple-mono-500-italic.woff2
Normal file
BIN
frontend_nuxt/public/fonts/maple-mono-500-italic.woff2
Normal file
Binary file not shown.
BIN
frontend_nuxt/public/fonts/maple-mono-500-normal.woff2
Normal file
BIN
frontend_nuxt/public/fonts/maple-mono-500-normal.woff2
Normal file
Binary file not shown.
BIN
frontend_nuxt/public/fonts/maple-mono-600-italic.woff2
Normal file
BIN
frontend_nuxt/public/fonts/maple-mono-600-italic.woff2
Normal file
Binary file not shown.
BIN
frontend_nuxt/public/fonts/maple-mono-600-normal.woff2
Normal file
BIN
frontend_nuxt/public/fonts/maple-mono-600-normal.woff2
Normal file
Binary file not shown.
BIN
frontend_nuxt/public/fonts/maple-mono-700-italic.woff2
Normal file
BIN
frontend_nuxt/public/fonts/maple-mono-700-italic.woff2
Normal file
Binary file not shown.
BIN
frontend_nuxt/public/fonts/maple-mono-700-normal.woff2
Normal file
BIN
frontend_nuxt/public/fonts/maple-mono-700-normal.woff2
Normal file
Binary file not shown.
BIN
frontend_nuxt/public/fonts/maple-mono-800-italic.woff2
Normal file
BIN
frontend_nuxt/public/fonts/maple-mono-800-italic.woff2
Normal file
Binary file not shown.
BIN
frontend_nuxt/public/fonts/maple-mono-800-normal.woff2
Normal file
BIN
frontend_nuxt/public/fonts/maple-mono-800-normal.woff2
Normal file
Binary file not shown.
@@ -24,6 +24,7 @@ export async function googleGetIdToken() {
|
||||
export function googleAuthorize() {
|
||||
const config = useRuntimeConfig()
|
||||
const GOOGLE_CLIENT_ID = config.public.googleClientId
|
||||
const WEBSITE_BASE_URL = config.public.websiteBaseUrl
|
||||
if (!GOOGLE_CLIENT_ID) {
|
||||
toast.error('Google 登录不可用, 请检查网络设置与VPN')
|
||||
return
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
import hljs from 'highlight.js'
|
||||
import 'highlight.js/styles/github.css'
|
||||
if (typeof window !== 'undefined') {
|
||||
const theme =
|
||||
document.documentElement.dataset.theme ||
|
||||
(window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
|
||||
if (theme === 'dark') {
|
||||
import('highlight.js/styles/atom-one-dark.css')
|
||||
} else {
|
||||
import('highlight.js/styles/atom-one-light.css')
|
||||
}
|
||||
}
|
||||
import MarkdownIt from 'markdown-it'
|
||||
import { toast } from '../main'
|
||||
import { tiebaEmoji } from './tiebaEmoji'
|
||||
@@ -86,7 +95,11 @@ const md = new MarkdownIt({
|
||||
} else {
|
||||
code = hljs.highlightAuto(str).value
|
||||
}
|
||||
return `<pre class="code-block"><button class="copy-code-btn">Copy</button><code class="hljs language-${lang || ''}">${code}</code></pre>`
|
||||
const lineNumbers = code
|
||||
.trim()
|
||||
.split('\n')
|
||||
.map(() => `<div class="line-number"></div>`)
|
||||
return `<pre class="code-block"><button class="copy-code-btn">Copy</button><div class="line-numbers">${lineNumbers.join('')}</div><code class="hljs language-${lang || ''}">${code.trim()}</code></pre>`
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user