mirror of
https://github.com/nagisa77/OpenIsle.git
synced 2026-02-06 23:21:16 +08:00
feat: add initial Nuxt frontend with SSR
This commit is contained in:
4
frontend_nuxt/.gitignore
vendored
Normal file
4
frontend_nuxt/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
.nuxt
|
||||
dist
|
||||
.output
|
||||
93
frontend_nuxt/app.vue
Normal file
93
frontend_nuxt/app.vue
Normal file
@@ -0,0 +1,93 @@
|
||||
<template>
|
||||
<div id="app">
|
||||
<div class="header-container">
|
||||
<HeaderComponent @toggle-menu="menuVisible = !menuVisible" :show-menu-btn="!hideMenu" />
|
||||
</div>
|
||||
|
||||
<div class="main-container">
|
||||
<div class="menu-container">
|
||||
<MenuComponent :visible="!hideMenu && menuVisible" @item-click="menuVisible = false" />
|
||||
</div>
|
||||
<div class="content" :class="{ 'menu-open': menuVisible && !hideMenu }">
|
||||
<NuxtPage />
|
||||
</div>
|
||||
</div>
|
||||
<GlobalPopups />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import HeaderComponent from '~/components/HeaderComponent.vue'
|
||||
import MenuComponent from '~/components/MenuComponent.vue'
|
||||
import GlobalPopups from '~/components/GlobalPopups.vue'
|
||||
|
||||
export default {
|
||||
name: 'App',
|
||||
components: { HeaderComponent, MenuComponent, GlobalPopups },
|
||||
data() {
|
||||
return {
|
||||
menuVisible: process.client ? window.innerWidth > 768 : false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
hideMenu() {
|
||||
return [
|
||||
'/login',
|
||||
'/signup',
|
||||
'/404',
|
||||
'/signup-reason',
|
||||
'/github-callback',
|
||||
'/twitter-callback',
|
||||
'/discord-callback',
|
||||
'/forgot-password',
|
||||
'/google-callback'
|
||||
].includes(this.$route.path)
|
||||
}
|
||||
},
|
||||
async mounted() {
|
||||
// placeholder for future global initializations
|
||||
},
|
||||
methods: {}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.header-container {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.menu-container {}
|
||||
|
||||
.content {
|
||||
/* height: calc(100vh - var(--header-height)); */
|
||||
padding-top: var(--header-height);
|
||||
flex: 1;
|
||||
max-width: 100%;
|
||||
transition: max-width 0.3s ease;
|
||||
background-color: var(--background-color);
|
||||
min-height: calc(100vh - var(--header-height));
|
||||
}
|
||||
|
||||
.content.menu-open {
|
||||
max-width: calc(100% - var(--menu-width));
|
||||
}
|
||||
|
||||
.main-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
max-width: var(--page-max-width);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
|
||||
.content,
|
||||
.content.menu-open {
|
||||
max-width: 100% !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
276
frontend_nuxt/assets/global.css
Normal file
276
frontend_nuxt/assets/global.css
Normal file
@@ -0,0 +1,276 @@
|
||||
:root {
|
||||
--primary-color-hover: rgb(9, 95, 105);
|
||||
--primary-color: rgb(10, 110, 120);
|
||||
--primary-color-disabled: rgba(93, 152, 156, 0.5);
|
||||
--header-height: 60px;
|
||||
--header-background-color: white;
|
||||
--header-border-color: lightgray;
|
||||
--header-text-color: black;
|
||||
--menu-background-color: white;
|
||||
--background-color: white;
|
||||
/* --background-color-blur: rgba(255, 255, 255, 0.57); */
|
||||
--background-color-blur: var(--background-color);
|
||||
--menu-border-color: lightgray;
|
||||
--normal-border-color: lightgray;
|
||||
--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);
|
||||
--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-mobile: 900px;
|
||||
--article-info-background-color: #f0f0f0;
|
||||
--activity-card-background-color: #fafafa;
|
||||
}
|
||||
|
||||
[data-theme='dark'] {
|
||||
--header-background-color: #2b2b2b;
|
||||
--header-border-color: #555;
|
||||
--primary-color: rgb(17, 182, 197);
|
||||
--primary-color-hover: rgb(13, 137, 151);
|
||||
--header-text-color: white;
|
||||
--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;
|
||||
--menu-selected-background-color: rgba(255, 255, 255, 0.1);
|
||||
--menu-text-color: white;
|
||||
--normal-background-color: #000000;
|
||||
--login-background-color: #575757;
|
||||
--login-background-color-hover: #717171;
|
||||
--text-color: #eee;
|
||||
--blockquote-text-color: #999;
|
||||
--article-info-background-color: #747373;
|
||||
--activity-card-background-color: #585858;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: 'Roboto', sans-serif;
|
||||
background-color: var(--normal-background-color);
|
||||
color: var(--text-color);
|
||||
/* 禁止滚动 */
|
||||
/* overflow: hidden; */
|
||||
}
|
||||
|
||||
/*************************
|
||||
* Vditor 自定义皮肤覆写
|
||||
*************************/
|
||||
.vditor {
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.vditor-toolbar--pin {
|
||||
top: var(--header-height) !important;
|
||||
}
|
||||
|
||||
.vditor-panel {
|
||||
min-width: 400px;
|
||||
}
|
||||
|
||||
.emoji {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* .vditor {
|
||||
--textarea-background-color: transparent;
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.vditor-reset {
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.vditor-toolbar {
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
} */
|
||||
|
||||
/* .vditor-toolbar {
|
||||
position: relative !important;
|
||||
} */
|
||||
|
||||
/*************************
|
||||
* Markdown 渲染样式
|
||||
*************************/
|
||||
.info-content-text ul,
|
||||
.info-content-text ol {
|
||||
padding-left: 1.5em;
|
||||
}
|
||||
|
||||
.info-content-text h1,
|
||||
.info-content-text h2 {
|
||||
border-bottom: 1px solid var(--normal-border-color);
|
||||
padding-bottom: 0.3em;
|
||||
margin-bottom: 0.8em;
|
||||
}
|
||||
|
||||
.info-content-text {
|
||||
word-break: break-word;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.info-content-text blockquote {
|
||||
margin: 1em 0;
|
||||
padding-left: 1em;
|
||||
border-left: 4px solid #d0d7de;
|
||||
color: var(--blockquote-text-color);
|
||||
}
|
||||
|
||||
.info-content-text pre {
|
||||
background-color: var(--normal-background-color);
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
line-height: 1.5;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.copy-code-btn {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
font-size: 12px;
|
||||
padding: 2px 6px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background-color: white;
|
||||
opacity: 0.8;
|
||||
color: black;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.copy-code-btn:hover {
|
||||
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);
|
||||
}
|
||||
|
||||
.info-content-text a {
|
||||
color: var(--primary-color);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.info-content-text a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.info-content-text img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.info-content-text table {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 1.2em 0;
|
||||
font-size: 14px;
|
||||
line-height: 1.45;
|
||||
overflow-x: auto; /* 小屏可横向滚动 */
|
||||
}
|
||||
|
||||
.info-content-text thead th {
|
||||
background-color: var(--primary-color);
|
||||
color: #fff;
|
||||
padding: 10px 14px;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .info-content-text thead th {
|
||||
background-color: var(--primary-color-hover); /* 暗色稍暗一点 */
|
||||
}
|
||||
|
||||
.info-content-text tbody tr:nth-child(even) {
|
||||
background-color: rgba(208, 250, 255, 0.25); /* 斑马纹 */
|
||||
}
|
||||
|
||||
[data-theme='dark'] .info-content-text tbody tr:nth-child(even) {
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.info-content-text th,
|
||||
.info-content-text td {
|
||||
border: 1px solid var(--menu-border-color);
|
||||
padding: 8px 14px;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.info-content-text tbody td {
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
/* 首列加粗,便于阅读 */
|
||||
.info-content-text tbody td:first-child {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 鼠标悬停行高亮 */
|
||||
.info-content-text tbody tr:hover {
|
||||
background-color: var(--menu-selected-background-color);
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.vditor {
|
||||
min-height: 100px;
|
||||
}
|
||||
|
||||
.vditor-toolbar {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.about-content h1,
|
||||
.info-content-text h1 {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.about-content h2,
|
||||
.info-content-text h2 {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.about-content p,
|
||||
.info-content-text p {
|
||||
font-size: 14px;
|
||||
margin-top: 3px;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
|
||||
.vditor-toolbar--pin {
|
||||
top: 0 !important;
|
||||
}
|
||||
|
||||
.about-content li,
|
||||
.info-content-text li {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.info-content-text pre {
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.vditor-panel {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
1
frontend_nuxt/assets/icons/discord.svg
Normal file
1
frontend_nuxt/assets/icons/discord.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Discord</title><path d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
28
frontend_nuxt/assets/icons/github.svg
Normal file
28
frontend_nuxt/assets/icons/github.svg
Normal file
@@ -0,0 +1,28 @@
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<title>GitHub icon</title>
|
||||
<path d="M12 .297c-6.63 0-12 5.373-12 12
|
||||
0 5.303 3.438 9.8 8.205 11.387
|
||||
.6 .113 .82-.258 .82-.577
|
||||
0-.285-.01-1.04-.015-2.04
|
||||
-3.338 .724-4.042-1.61-4.042-1.61
|
||||
C4.422 16.07 3.633 15.7 3.633 15.7
|
||||
c-1.087-.744 .084-.729 .084-.729
|
||||
1.205 .084 1.84 1.236 1.84 1.236
|
||||
1.07 1.835 2.809 1.305 3.495 .998
|
||||
.108-.775 .418-1.305 .762-1.605
|
||||
-2.665-.3-5.467-1.335-5.467-5.93
|
||||
0-1.31 .468-2.38 1.236-3.22
|
||||
-.123-.303-.536-1.523 .117-3.176
|
||||
0 0 1.008-.322 3.302 1.23
|
||||
.957-.266 1.983-.399 3.003-.404
|
||||
1.02 .005 2.047 .138 3.006 .404
|
||||
2.292-1.552 3.298-1.23 3.298-1.23
|
||||
.654 1.653 .242 2.873 .119 3.176
|
||||
.77 .84 1.235 1.91 1.235 3.22
|
||||
0 4.61-2.807 5.625-5.479 5.921
|
||||
.43 .37 .823 1.102 .823 2.222
|
||||
0 1.606-.014 2.896-.014 3.286
|
||||
0 .321 .216 .694 .825 .576
|
||||
C20.565 22.092 24 17.592 24 12.297
|
||||
c0-6.627 -5.373-12 -12-12z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
28
frontend_nuxt/assets/icons/google.svg
Normal file
28
frontend_nuxt/assets/icons/google.svg
Normal file
@@ -0,0 +1,28 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
viewBox="0 0 48 48">
|
||||
<defs>
|
||||
<!-- 定义整块 “G” 轮廓,用作剪裁路径 -->
|
||||
<path id="a"
|
||||
d="M44.5 20H24v8.5h11.8C34.7 33.9 30.1 37 24 37
|
||||
c-7.2 0-13-5.8-13-13s5.8-13 13-13
|
||||
c3.1 0 5.9 1.1 8.1 2.9l6.4-6.4C34.6 4.1 29.6 2 24 2
|
||||
11.8 2 2 11.8 2 24s9.8 22 22 22
|
||||
c11 0 21-8 21-22 0-1.3-.2-2.7-.5-4z"/>
|
||||
</defs>
|
||||
|
||||
<!-- 应用剪裁后依次绘制四段色块 -->
|
||||
<clipPath id="b">
|
||||
<use xlink:href="#a" overflow="visible"/>
|
||||
</clipPath>
|
||||
|
||||
<!-- 黄色 (#FBBC05) -->
|
||||
<path clip-path="url(#b)" fill="#FBBC05" d="M0 37V11l17 13z"/>
|
||||
<!-- 红色 (#EA4335) -->
|
||||
<path clip-path="url(#b)" fill="#EA4335" d="M0 11l17 13 7-6.1L48 14V0H0z"/>
|
||||
<!-- 绿色 (#34A853) -->
|
||||
<path clip-path="url(#b)" fill="#34A853" d="M0 37l30-23 7.9 1L48 0v48H0z"/>
|
||||
<!-- 蓝色 (#4285F4) -->
|
||||
<path clip-path="url(#b)" fill="#4285F4" d="M48 48L17 24l-4-3 35-10z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
4
frontend_nuxt/assets/icons/twitter.svg
Normal file
4
frontend_nuxt/assets/icons/twitter.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<title>Twitter icon</title>
|
||||
<path d="M23.954 4.569c-.885.389-1.83.654-2.825.775 1.014-.611 1.794-1.574 2.163-2.723-.949.555-2.005.959-3.127 1.184-.897-.959-2.173-1.559-3.591-1.559-2.717 0-4.92 2.203-4.92 4.917 0 .39.045.765.127 1.124-4.083-.205-7.697-2.159-10.126-5.134-.422.722-.666 1.561-.666 2.475 0 1.709.87 3.214 2.188 4.096-.807-.026-1.566-.248-2.229-.616v.061c0 2.385 1.693 4.374 3.946 4.827-.413.111-.849.171-1.296.171-.314 0-.615-.03-.916-.086.631 1.953 2.445 3.376 4.6 3.416-1.68 1.318-3.808 2.105-6.102 2.105-.39 0-.779-.023-1.17-.069 2.189 1.394 4.768 2.209 7.548 2.209 9.051 0 14.001-7.496 14.001-13.986 0-.21 0-.42-.015-.63.961-.689 1.8-1.56 2.46-2.548l-.047-.02z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 764 B |
35
frontend_nuxt/assets/toast.css
Normal file
35
frontend_nuxt/assets/toast.css
Normal file
@@ -0,0 +1,35 @@
|
||||
.Vue-Toastification__toast {
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.Vue-Toastification__toast--info {
|
||||
background-color: #d0e9ff;
|
||||
color: #1b6ec2;
|
||||
}
|
||||
.Vue-Toastification__toast--success {
|
||||
background-color: #dff6dd;
|
||||
color: #2b7a2b;
|
||||
}
|
||||
.Vue-Toastification__toast--error {
|
||||
background-color: #f99a9a;
|
||||
color: #b73737;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.Vue-Toastification__container.open-isle-toast-style-v1 {
|
||||
width: auto;
|
||||
max-width: 90vw;
|
||||
right: 0.5em;
|
||||
left: auto;
|
||||
}
|
||||
|
||||
.Vue-Toastification__toast-body {
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.Vue-Toastification__close-button {
|
||||
font-size: 18px;
|
||||
line-height: 18px;
|
||||
}
|
||||
}
|
||||
79
frontend_nuxt/components/ActivityPopup.vue
Normal file
79
frontend_nuxt/components/ActivityPopup.vue
Normal file
@@ -0,0 +1,79 @@
|
||||
<template>
|
||||
<BasePopup :visible="visible" @close="close">
|
||||
<div class="activity-popup">
|
||||
<img v-if="icon" :src="icon" class="activity-popup-icon" alt="activity icon" />
|
||||
<div class="activity-popup-text">{{ text }}</div>
|
||||
<div class="activity-popup-actions">
|
||||
<div class="activity-popup-button" @click="gotoActivity">立即前往</div>
|
||||
<div class="activity-popup-close" @click="close">稍后再说</div>
|
||||
</div>
|
||||
</div>
|
||||
</BasePopup>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import BasePopup from '~/components/BasePopup.vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
export default {
|
||||
name: 'ActivityPopup',
|
||||
components: { BasePopup },
|
||||
props: {
|
||||
visible: { type: Boolean, default: false },
|
||||
icon: String,
|
||||
text: String
|
||||
},
|
||||
emits: ['close'],
|
||||
setup (props, { emit }) {
|
||||
const router = useRouter()
|
||||
const gotoActivity = () => {
|
||||
emit('close')
|
||||
router.push('/activities')
|
||||
}
|
||||
const close = () => emit('close')
|
||||
return { gotoActivity, close }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.activity-popup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
gap: 10px;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.activity-popup-icon {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
object-fit: contain;
|
||||
}
|
||||
.activity-popup-actions {
|
||||
margin-top: 10px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 20px;
|
||||
}
|
||||
.activity-popup-button {
|
||||
background-color: var(--primary-color);
|
||||
color: #fff;
|
||||
padding: 8px 16px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.activity-popup-button:hover {
|
||||
background-color: var(--primary-color-hover);
|
||||
}
|
||||
.activity-popup-close {
|
||||
cursor: pointer;
|
||||
color: var(--primary-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.activity-popup-close:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
71
frontend_nuxt/components/ArticleCategory.vue
Normal file
71
frontend_nuxt/components/ArticleCategory.vue
Normal file
@@ -0,0 +1,71 @@
|
||||
<template>
|
||||
<div class="article-category-container" v-if="category">
|
||||
<div class="article-info-item" @click="gotoCategory">
|
||||
<img
|
||||
v-if="category.smallIcon"
|
||||
class="article-info-item-img"
|
||||
:src="category.smallIcon"
|
||||
:alt="category.name"
|
||||
/>
|
||||
<div class="article-info-item-text">{{ category.name }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
export default {
|
||||
name: 'ArticleCategory',
|
||||
props: {
|
||||
category: { type: Object, default: null }
|
||||
},
|
||||
setup(props) {
|
||||
const router = useRouter()
|
||||
const gotoCategory = () => {
|
||||
if (!props.category) return
|
||||
const value = encodeURIComponent(props.category.id ?? props.category.name)
|
||||
router.push({ path: '/', query: { category: value } }).then(() => {
|
||||
window.location.reload()
|
||||
})
|
||||
}
|
||||
return { gotoCategory }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.article-category-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.article-info-item {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 5px;
|
||||
align-items: center;
|
||||
font-size: 14px;
|
||||
padding: 2px 4px;
|
||||
background-color: var(--article-info-background-color);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.article-info-item-img {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.article-info-item-img {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
.article-info-item {
|
||||
font-size: 10px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
79
frontend_nuxt/components/ArticleTags.vue
Normal file
79
frontend_nuxt/components/ArticleTags.vue
Normal file
@@ -0,0 +1,79 @@
|
||||
<template>
|
||||
<div class="article-tags-container">
|
||||
<div
|
||||
class="article-info-item"
|
||||
v-for="tag in tags"
|
||||
:key="tag.id || tag.name"
|
||||
@click="gotoTag(tag)"
|
||||
>
|
||||
<img
|
||||
v-if="tag.smallIcon"
|
||||
class="article-info-item-img"
|
||||
:src="tag.smallIcon"
|
||||
:alt="tag.name"
|
||||
/>
|
||||
<div class="article-info-item-text">{{ tag.name }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
export default {
|
||||
name: 'ArticleTags',
|
||||
props: {
|
||||
tags: { type: Array, default: () => [] }
|
||||
},
|
||||
setup() {
|
||||
const router = useRouter()
|
||||
const gotoTag = tag => {
|
||||
const value = encodeURIComponent(tag.id ?? tag.name)
|
||||
router.push({ path: '/', query: { tags: value } }).then(() => {
|
||||
window.location.reload()
|
||||
})
|
||||
}
|
||||
return { gotoTag }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
.article-tags-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
|
||||
.article-info-item {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 5px;
|
||||
align-items: center;
|
||||
font-size: 14px;
|
||||
padding: 2px 4px;
|
||||
background-color: var(--article-info-background-color);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.article-info-item-img {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.article-info-item-img {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
.article-info-item {
|
||||
font-size: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
58
frontend_nuxt/components/BasePopup.vue
Normal file
58
frontend_nuxt/components/BasePopup.vue
Normal file
@@ -0,0 +1,58 @@
|
||||
<template>
|
||||
<div v-if="visible" class="popup">
|
||||
<div class="popup-overlay" @click="onOverlayClick"></div>
|
||||
<div class="popup-content">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'BasePopup',
|
||||
props: {
|
||||
visible: { type: Boolean, default: false },
|
||||
closeOnOverlay: { type: Boolean, default: true }
|
||||
},
|
||||
emits: ['close'],
|
||||
methods: {
|
||||
onOverlayClick () {
|
||||
if (this.closeOnOverlay) this.$emit('close')
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.popup {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1100;
|
||||
}
|
||||
.popup-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
backdrop-filter: blur(2px);
|
||||
-webkit-backdrop-filter: blur(2px);
|
||||
}
|
||||
.popup-content {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
background-color: var(--background-color);
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
max-width: 80%;
|
||||
max-height: 80%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
86
frontend_nuxt/components/CategorySelect.vue
Normal file
86
frontend_nuxt/components/CategorySelect.vue
Normal file
@@ -0,0 +1,86 @@
|
||||
<template>
|
||||
<Dropdown v-model="selected" :fetch-options="fetchCategories" placeholder="选择分类" :initial-options="providedOptions">
|
||||
<template #option="{ option }">
|
||||
<div class="option-container">
|
||||
<div class="option-main">
|
||||
<template v-if="option.icon">
|
||||
<img v-if="isImageIcon(option.icon)" :src="option.icon" class="option-icon" :alt="option.name" />
|
||||
<i v-else :class="['option-icon', option.icon]"></i>
|
||||
</template>
|
||||
<span>{{ option.name }}</span>
|
||||
<span class="option-count" v-if="option.count > 0"> x {{ option.count }}</span>
|
||||
</div>
|
||||
<div v-if="option.description" class="option-desc">{{ option.description }}</div>
|
||||
</div>
|
||||
</template>
|
||||
</Dropdown>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { API_BASE_URL } from '~/main'
|
||||
import Dropdown from '~/components/Dropdown.vue'
|
||||
|
||||
export default {
|
||||
name: 'CategorySelect',
|
||||
components: { Dropdown },
|
||||
props: {
|
||||
modelValue: { type: [String, Number], default: '' },
|
||||
options: { type: Array, default: () => [] }
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
setup(props, { emit }) {
|
||||
const providedOptions = ref(Array.isArray(props.options) ? [...props.options] : [])
|
||||
|
||||
watch(
|
||||
() => props.options,
|
||||
val => {
|
||||
providedOptions.value = Array.isArray(val) ? [...val] : []
|
||||
}
|
||||
)
|
||||
|
||||
const fetchCategories = async () => {
|
||||
const res = await fetch(`${API_BASE_URL}/api/categories`)
|
||||
if (!res.ok) return []
|
||||
const data = await res.json()
|
||||
return [{ id: '', name: '无分类' }, ...data]
|
||||
}
|
||||
|
||||
const isImageIcon = icon => {
|
||||
if (!icon) return false
|
||||
return /^https?:\/\//.test(icon) || icon.startsWith('/')
|
||||
}
|
||||
|
||||
const selected = computed({
|
||||
get: () => props.modelValue,
|
||||
set: v => emit('update:modelValue', v)
|
||||
})
|
||||
|
||||
return { fetchCategories, selected, isImageIcon, providedOptions }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.option-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.option-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.option-desc {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.option-count {
|
||||
font-weight: bold;
|
||||
opacity: 0.4;
|
||||
}
|
||||
</style>
|
||||
414
frontend_nuxt/components/Dropdown.vue
Normal file
414
frontend_nuxt/components/Dropdown.vue
Normal file
@@ -0,0 +1,414 @@
|
||||
<template>
|
||||
<div class="dropdown" ref="wrapper">
|
||||
<div class="dropdown-display" @click="toggle">
|
||||
<slot
|
||||
name="display"
|
||||
:selected="selectedLabels"
|
||||
:toggle="toggle"
|
||||
:search="search"
|
||||
:setSearch="setSearch"
|
||||
>
|
||||
<template v-if="multiple">
|
||||
<span v-if="selectedLabels.length">
|
||||
<template v-for="(label, idx) in selectedLabels" :key="label.id">
|
||||
<div class="selected-label">
|
||||
<template v-if="label.icon">
|
||||
<img
|
||||
v-if="isImageIcon(label.icon)"
|
||||
:src="label.icon"
|
||||
class="option-icon"
|
||||
:alt="label.name"
|
||||
/>
|
||||
<i v-else :class="['option-icon', label.icon]"></i>
|
||||
</template>
|
||||
<span>{{ label.name }}</span>
|
||||
</div>
|
||||
<span v-if="idx !== selectedLabels.length - 1">, </span>
|
||||
</template>
|
||||
</span>
|
||||
<span v-else class="placeholder">{{ placeholder }}</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span v-if="selectedLabels.length">
|
||||
<div class="selected-label">
|
||||
<template v-if="selectedLabels[0].icon">
|
||||
<img
|
||||
v-if="isImageIcon(selectedLabels[0].icon)"
|
||||
:src="selectedLabels[0].icon"
|
||||
class="option-icon"
|
||||
:alt="selectedLabels[0].name"
|
||||
/>
|
||||
<i v-else :class="['option-icon', selectedLabels[0].icon]"></i>
|
||||
</template>
|
||||
<span>{{ selectedLabels[0].name }}</span>
|
||||
</div>
|
||||
</span>
|
||||
<span v-else class="placeholder">{{ placeholder }}</span>
|
||||
</template>
|
||||
<i class="fas fa-caret-down dropdown-caret"></i>
|
||||
</slot>
|
||||
</div>
|
||||
<div
|
||||
v-if="
|
||||
open &&
|
||||
!isMobile &&
|
||||
(loading || filteredOptions.length > 0 || showSearch)
|
||||
"
|
||||
:class="['dropdown-menu', menuClass]"
|
||||
v-click-outside="close"
|
||||
>
|
||||
<div v-if="showSearch" class="dropdown-search">
|
||||
<i class="fas fa-search search-icon"></i>
|
||||
<input type="text" v-model="search" placeholder="搜索" />
|
||||
</div>
|
||||
<div v-if="loading" class="dropdown-loading">
|
||||
<l-hatch
|
||||
size="20"
|
||||
stroke="4"
|
||||
speed="3.5"
|
||||
color="var(--primary-color)"
|
||||
></l-hatch>
|
||||
</div>
|
||||
<template v-else>
|
||||
<div
|
||||
v-for="o in filteredOptions"
|
||||
:key="o.id"
|
||||
@click="select(o.id)"
|
||||
:class="[
|
||||
'dropdown-option',
|
||||
optionClass,
|
||||
{ selected: isSelected(o.id) },
|
||||
]"
|
||||
>
|
||||
<slot name="option" :option="o" :isSelected="isSelected(o.id)">
|
||||
<template v-if="o.icon">
|
||||
<img
|
||||
v-if="isImageIcon(o.icon)"
|
||||
:src="o.icon"
|
||||
class="option-icon"
|
||||
:alt="o.name"
|
||||
/>
|
||||
<i v-else :class="['option-icon', o.icon]"></i>
|
||||
</template>
|
||||
<span>{{ o.name }}</span>
|
||||
</slot>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<Teleport to="body">
|
||||
<div v-if="open && isMobile" class="dropdown-mobile-page">
|
||||
<div class="dropdown-mobile-header">
|
||||
<i class="fas fa-arrow-left" @click="close"></i>
|
||||
<span class="mobile-title">{{ placeholder }}</span>
|
||||
</div>
|
||||
<div class="dropdown-mobile-menu">
|
||||
<div v-if="showSearch" class="dropdown-search">
|
||||
<i class="fas fa-search search-icon"></i>
|
||||
<input type="text" v-model="search" placeholder="搜索" />
|
||||
</div>
|
||||
<div v-if="loading" class="dropdown-loading">
|
||||
<l-hatch
|
||||
size="20"
|
||||
stroke="4"
|
||||
speed="3.5"
|
||||
color="var(--primary-color)"
|
||||
></l-hatch>
|
||||
</div>
|
||||
<template v-else>
|
||||
<div
|
||||
v-for="o in filteredOptions"
|
||||
:key="o.id"
|
||||
@click="select(o.id)"
|
||||
:class="[
|
||||
'dropdown-option',
|
||||
optionClass,
|
||||
{ selected: isSelected(o.id) },
|
||||
]"
|
||||
>
|
||||
<slot name="option" :option="o" :isSelected="isSelected(o.id)">
|
||||
<template v-if="o.icon">
|
||||
<img
|
||||
v-if="isImageIcon(o.icon)"
|
||||
:src="o.icon"
|
||||
class="option-icon"
|
||||
:alt="o.name"
|
||||
/>
|
||||
<i v-else :class="['option-icon', o.icon]"></i>
|
||||
</template>
|
||||
<span>{{ o.name }}</span>
|
||||
</slot>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref, computed, watch, onMounted } from "vue"
|
||||
import { hatch } from "ldrs"
|
||||
import { isMobile } from "~/utils/screen"
|
||||
if (process.client) {
|
||||
hatch.register()
|
||||
}
|
||||
|
||||
export default {
|
||||
name: "BaseDropdown",
|
||||
props: {
|
||||
modelValue: { type: [Array, String, Number], default: () => [] },
|
||||
placeholder: { type: String, default: "返回" },
|
||||
multiple: { type: Boolean, default: false },
|
||||
fetchOptions: { type: Function, required: true },
|
||||
remote: { type: Boolean, default: false },
|
||||
menuClass: { type: String, default: "" },
|
||||
optionClass: { type: String, default: "" },
|
||||
showSearch: { type: Boolean, default: true },
|
||||
initialOptions: { type: Array, default: () => [] },
|
||||
},
|
||||
emits: ["update:modelValue", "update:search", "close"],
|
||||
setup(props, { emit, expose }) {
|
||||
const open = ref(false)
|
||||
const search = ref("")
|
||||
const setSearch = (val) => {
|
||||
search.value = val
|
||||
}
|
||||
const options = ref(
|
||||
Array.isArray(props.initialOptions) ? [...props.initialOptions] : []
|
||||
)
|
||||
const loaded = ref(false)
|
||||
const loading = ref(false)
|
||||
const wrapper = ref(null)
|
||||
|
||||
const toggle = () => {
|
||||
open.value = !open.value
|
||||
if (!open.value) emit("close")
|
||||
}
|
||||
|
||||
const close = () => {
|
||||
open.value = false
|
||||
emit("close")
|
||||
}
|
||||
|
||||
const select = (id) => {
|
||||
if (props.multiple) {
|
||||
const arr = Array.isArray(props.modelValue) ? [...props.modelValue] : []
|
||||
const idx = arr.indexOf(id)
|
||||
if (idx > -1) {
|
||||
arr.splice(idx, 1)
|
||||
} else {
|
||||
arr.push(id)
|
||||
}
|
||||
emit("update:modelValue", arr)
|
||||
} else {
|
||||
emit("update:modelValue", id)
|
||||
close()
|
||||
}
|
||||
search.value = ""
|
||||
}
|
||||
|
||||
const filteredOptions = computed(() => {
|
||||
if (props.remote) return options.value
|
||||
if (!search.value) return options.value
|
||||
return options.value.filter((o) =>
|
||||
o.name.toLowerCase().includes(search.value.toLowerCase())
|
||||
)
|
||||
})
|
||||
|
||||
const loadOptions = async (kw = "") => {
|
||||
if (!props.remote && loaded.value) return
|
||||
try {
|
||||
loading.value = true
|
||||
const res = await props.fetchOptions(props.remote ? kw : undefined)
|
||||
options.value = Array.isArray(res) ? res : []
|
||||
if (!props.remote) loaded.value = true
|
||||
} catch {
|
||||
options.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.initialOptions,
|
||||
(val) => {
|
||||
if (Array.isArray(val)) {
|
||||
options.value = [...val]
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
watch(open, async (val) => {
|
||||
if (val) {
|
||||
if (props.remote) {
|
||||
await loadOptions(search.value)
|
||||
} else if (!loaded.value) {
|
||||
await loadOptions()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
watch(search, async (val) => {
|
||||
emit("update:search", val)
|
||||
if (props.remote && open.value) {
|
||||
await loadOptions(val)
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
if (!props.remote) {
|
||||
loadOptions()
|
||||
}
|
||||
})
|
||||
|
||||
const selectedLabels = computed(() => {
|
||||
if (props.multiple) {
|
||||
return options.value.filter((o) =>
|
||||
(props.modelValue || []).includes(o.id)
|
||||
)
|
||||
}
|
||||
const match = options.value.find((o) => o.id === props.modelValue)
|
||||
return match ? [match] : []
|
||||
})
|
||||
|
||||
const isSelected = (id) => {
|
||||
return selectedLabels.value.some((label) => label.id === id)
|
||||
}
|
||||
|
||||
const isImageIcon = (icon) => {
|
||||
if (!icon) return false
|
||||
return /^https?:\/\//.test(icon) || icon.startsWith("/")
|
||||
}
|
||||
|
||||
expose({ toggle, close })
|
||||
|
||||
return {
|
||||
open,
|
||||
toggle,
|
||||
close,
|
||||
select,
|
||||
search,
|
||||
filteredOptions,
|
||||
wrapper,
|
||||
selectedLabels,
|
||||
isSelected,
|
||||
loading,
|
||||
isImageIcon,
|
||||
setSearch,
|
||||
isMobile,
|
||||
}
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.dropdown {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.dropdown-display {
|
||||
border: 1px solid var(--normal-border-color);
|
||||
border-radius: 5px;
|
||||
padding: 5px 10px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
color: gray;
|
||||
}
|
||||
|
||||
.dropdown-menu {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: var(--background-color);
|
||||
border: 1px solid var(--normal-border-color);
|
||||
z-index: 10000;
|
||||
max-height: 200px;
|
||||
min-width: 350px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.selected-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.dropdown-search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 5px;
|
||||
border-bottom: 1px solid var(--normal-border-color);
|
||||
}
|
||||
|
||||
.dropdown-search input {
|
||||
flex: 1;
|
||||
border: none;
|
||||
outline: none;
|
||||
margin-left: 5px;
|
||||
background-color: var(--menu-background-color);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.dropdown-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 10px 20px;
|
||||
gap: 5px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dropdown-option.selected {
|
||||
background-color: var(--menu-selected-background-color);
|
||||
}
|
||||
|
||||
.dropdown-option:hover {
|
||||
background-color: var(--menu-selected-background-color);
|
||||
}
|
||||
|
||||
.option-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.dropdown-loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.dropdown-mobile-page {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: var(--menu-background-color);
|
||||
z-index: 1300;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.dropdown-mobile-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px;
|
||||
border-bottom: 1px solid var(--normal-border-color);
|
||||
}
|
||||
|
||||
.dropdown-mobile-menu {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
89
frontend_nuxt/components/DropdownMenu.vue
Normal file
89
frontend_nuxt/components/DropdownMenu.vue
Normal file
@@ -0,0 +1,89 @@
|
||||
<template>
|
||||
<div class="dropdown-wrapper" ref="wrapper">
|
||||
<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 }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref, onMounted, onBeforeUnmount } from 'vue'
|
||||
export default {
|
||||
name: 'DropdownMenu',
|
||||
props: {
|
||||
items: { type: Array, default: () => [] }
|
||||
},
|
||||
setup(props, { expose }) {
|
||||
const visible = ref(false)
|
||||
const wrapper = ref(null)
|
||||
const toggle = () => {
|
||||
visible.value = !visible.value
|
||||
}
|
||||
const close = () => {
|
||||
visible.value = false
|
||||
}
|
||||
const handle = item => {
|
||||
close()
|
||||
if (item && typeof item.onClick === 'function') {
|
||||
item.onClick()
|
||||
}
|
||||
}
|
||||
const clickOutside = e => {
|
||||
if (wrapper.value && !wrapper.value.contains(e.target)) {
|
||||
close()
|
||||
}
|
||||
}
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', clickOutside)
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('click', clickOutside)
|
||||
})
|
||||
expose({ close })
|
||||
return { visible, toggle, wrapper, handle }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dropdown-wrapper {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
.dropdown-trigger {
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.dropdown-menu-container {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
right: 0;
|
||||
background-color: var(--menu-background-color);
|
||||
border: 1px solid var(--normal-border-color);
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
|
||||
border-radius: 8px;
|
||||
min-width: 100px;
|
||||
z-index: 2000;
|
||||
}
|
||||
|
||||
.dropdown-item {
|
||||
padding: 8px 16px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.dropdown-item:hover {
|
||||
background-color: var(--menu-selected-background-color);
|
||||
}
|
||||
</style>
|
||||
53
frontend_nuxt/components/GlobalPopups.vue
Normal file
53
frontend_nuxt/components/GlobalPopups.vue
Normal file
@@ -0,0 +1,53 @@
|
||||
<template>
|
||||
<div>
|
||||
<ActivityPopup
|
||||
:visible="showMilkTeaPopup"
|
||||
:icon="milkTeaIcon"
|
||||
text="建站送奶茶活动火热进行中,快来参与吧!"
|
||||
@close="closeMilkTeaPopup"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import ActivityPopup from '~/components/ActivityPopup.vue'
|
||||
import { API_BASE_URL } from '~/main'
|
||||
|
||||
export default {
|
||||
name: 'GlobalPopups',
|
||||
components: { ActivityPopup },
|
||||
data () {
|
||||
return {
|
||||
showMilkTeaPopup: false,
|
||||
milkTeaIcon: ''
|
||||
}
|
||||
},
|
||||
async mounted () {
|
||||
await this.checkMilkTeaActivity()
|
||||
},
|
||||
methods: {
|
||||
async checkMilkTeaActivity () {
|
||||
if (!process.client) return
|
||||
if (localStorage.getItem('milkTeaActivityPopupShown')) return
|
||||
try {
|
||||
const res = await fetch(`${API_BASE_URL}/api/activities`)
|
||||
if (res.ok) {
|
||||
const list = await res.json()
|
||||
const a = list.find(i => i.type === 'MILK_TEA' && !i.ended)
|
||||
if (a) {
|
||||
this.milkTeaIcon = a.icon
|
||||
this.showMilkTeaPopup = true
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore network errors
|
||||
}
|
||||
},
|
||||
closeMilkTeaPopup () {
|
||||
if (!process.client) return
|
||||
localStorage.setItem('milkTeaActivityPopupShown', 'true')
|
||||
this.showMilkTeaPopup = false
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
312
frontend_nuxt/components/HeaderComponent.vue
Normal file
312
frontend_nuxt/components/HeaderComponent.vue
Normal file
@@ -0,0 +1,312 @@
|
||||
<template>
|
||||
<header class="header">
|
||||
<div class="header-content">
|
||||
<div class="header-content-left">
|
||||
<div v-if="showMenuBtn" class="menu-btn-wrapper">
|
||||
<button class="menu-btn" @click="$emit('toggle-menu')">
|
||||
<i class="fas fa-bars"></i>
|
||||
</button>
|
||||
<span v-if="isMobile && unreadCount > 0" class="menu-unread-dot"></span>
|
||||
</div>
|
||||
<div class="logo-container" @click="goToHome">
|
||||
<img alt="OpenIsle" src="https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/image.png"
|
||||
width="60" height="60">
|
||||
<div class="logo-text">OpenIsle</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isLogin" class="header-content-right">
|
||||
<div v-if="isMobile" class="search-icon" @click="search">
|
||||
<i class="fas fa-search"></i>
|
||||
</div>
|
||||
<DropdownMenu ref="userMenu" :items="headerMenuItems">
|
||||
<template #trigger>
|
||||
<div class="avatar-container">
|
||||
<img class="avatar-img" :src="avatar" alt="avatar">
|
||||
<i class="fas fa-caret-down dropdown-icon"></i>
|
||||
</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>
|
||||
<div class="header-content-item-main" @click="goToLogin">登录</div>
|
||||
<div class="header-content-item-secondary" @click="goToSignup">注册</div>
|
||||
</div>
|
||||
|
||||
<SearchDropdown ref="searchDropdown" v-if="isMobile && showSearch" @close="closeSearch" />
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { authState, clearToken, loadCurrentUser } from '~/utils/auth'
|
||||
import { watch, nextTick } from 'vue'
|
||||
import { fetchUnreadCount, notificationState } from '~/utils/notification'
|
||||
import DropdownMenu from '~/components/DropdownMenu.vue'
|
||||
import SearchDropdown from '~/components/SearchDropdown.vue'
|
||||
import { isMobile } from '~/utils/screen'
|
||||
|
||||
export default {
|
||||
name: 'HeaderComponent',
|
||||
components: { DropdownMenu, SearchDropdown },
|
||||
props: {
|
||||
showMenuBtn: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
avatar: '',
|
||||
showSearch: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
isLogin() {
|
||||
return authState.loggedIn
|
||||
},
|
||||
isMobile() {
|
||||
return isMobile.value
|
||||
},
|
||||
headerMenuItems() {
|
||||
return [
|
||||
{ text: '设置', onClick: this.goToSettings },
|
||||
{ text: '个人主页', onClick: this.goToProfile },
|
||||
{ text: '退出', onClick: this.goToLogout }
|
||||
]
|
||||
},
|
||||
unreadCount() {
|
||||
return notificationState.unreadCount
|
||||
}
|
||||
},
|
||||
async mounted() {
|
||||
const updateAvatar = async () => {
|
||||
if (authState.loggedIn) {
|
||||
const user = await loadCurrentUser()
|
||||
if (user && user.avatar) {
|
||||
this.avatar = user.avatar
|
||||
}
|
||||
}
|
||||
}
|
||||
const updateUnread = async () => {
|
||||
if (authState.loggedIn) {
|
||||
await fetchUnreadCount()
|
||||
} else {
|
||||
notificationState.unreadCount = 0
|
||||
}
|
||||
}
|
||||
|
||||
await updateAvatar()
|
||||
await updateUnread()
|
||||
|
||||
watch(() => authState.loggedIn, async () => {
|
||||
await updateAvatar()
|
||||
await updateUnread()
|
||||
})
|
||||
|
||||
watch(() => this.$route.fullPath, () => {
|
||||
if (this.$refs.userMenu) this.$refs.userMenu.close()
|
||||
this.showSearch = false
|
||||
})
|
||||
},
|
||||
|
||||
|
||||
methods: {
|
||||
goToHome() {
|
||||
this.$router.push('/').then(() => {
|
||||
window.location.reload()
|
||||
})
|
||||
},
|
||||
search() {
|
||||
this.showSearch = true
|
||||
nextTick(() => {
|
||||
this.$refs.searchDropdown.toggle()
|
||||
})
|
||||
},
|
||||
closeSearch() {
|
||||
nextTick(() => {
|
||||
this.showSearch = false
|
||||
})
|
||||
},
|
||||
goToLogin() {
|
||||
this.$router.push('/login')
|
||||
},
|
||||
goToSettings() {
|
||||
this.$router.push('/settings')
|
||||
},
|
||||
async goToProfile() {
|
||||
if (!authState.loggedIn) {
|
||||
this.$router.push('/login')
|
||||
return
|
||||
}
|
||||
let id = authState.username || authState.userId
|
||||
if (!id) {
|
||||
const user = await loadCurrentUser()
|
||||
if (user) {
|
||||
id = user.username || user.id
|
||||
}
|
||||
}
|
||||
if (id) {
|
||||
this.$router.push(`/users/${id}`).then(() => {
|
||||
window.location.reload()
|
||||
})
|
||||
}
|
||||
},
|
||||
goToSignup() {
|
||||
this.$router.push('/signup')
|
||||
},
|
||||
goToLogout() {
|
||||
clearToken()
|
||||
this.$router.push('/login')
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
height: var(--header-height);
|
||||
background-color: var(--background-color-blur);
|
||||
backdrop-filter: blur(10px);
|
||||
color: var(--header-text-color);
|
||||
border-bottom: 1px solid var(--header-border-color);
|
||||
}
|
||||
|
||||
.logo-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
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);
|
||||
}
|
||||
|
||||
.header-content-left {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.header-content-right {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.menu-btn {
|
||||
font-size: 24px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
opacity: 0.4;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.menu-btn-wrapper {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.menu-unread-dot {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 10px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background-color: #ff4d4f;
|
||||
}
|
||||
|
||||
.menu-btn:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.header-content-item-main {
|
||||
background-color: var(--primary-color);
|
||||
color: white;
|
||||
padding: 8px 16px;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.header-content-item-main:hover {
|
||||
background-color: var(--primary-color-hover);
|
||||
}
|
||||
|
||||
.header-content-item-secondary {
|
||||
color: var(--primary-color);
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.avatar-container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.avatar-img {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background-color: lightgray;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.dropdown-icon {
|
||||
margin-left: 5px;
|
||||
}
|
||||
|
||||
.dropdown-item {
|
||||
padding: 8px 16px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dropdown-item:hover {
|
||||
background-color: var(--menu-selected-background-color);
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.header-content {
|
||||
padding-left: 15px;
|
||||
padding-right: 15px;
|
||||
width: calc(100% - 30px);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.header-content-item-secondary {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.logo-text {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
446
frontend_nuxt/components/MenuComponent.vue
Normal file
446
frontend_nuxt/components/MenuComponent.vue
Normal file
@@ -0,0 +1,446 @@
|
||||
<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="handleHomeClick"
|
||||
>
|
||||
<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 categories" :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 tags" :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 class="menu-footer">
|
||||
<div class="menu-footer-btn" @click="cycleTheme">
|
||||
<i :class="iconClass"></i>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</transition>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { themeState, cycleTheme, ThemeMode } from '~/utils/theme'
|
||||
import { authState } from '~/utils/auth'
|
||||
import { fetchUnreadCount, notificationState } from '~/utils/notification'
|
||||
import { watch } from 'vue'
|
||||
import { API_BASE_URL } from '~/main'
|
||||
import { hatch } from 'ldrs'
|
||||
if (process.client) {
|
||||
hatch.register()
|
||||
}
|
||||
|
||||
export default {
|
||||
name: 'MenuComponent',
|
||||
props: {
|
||||
visible: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
categories: [],
|
||||
tags: [],
|
||||
categoryOpen: true,
|
||||
tagOpen: true,
|
||||
isLoadingCategory: false,
|
||||
isLoadingTag: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
iconClass() {
|
||||
switch (themeState.mode) {
|
||||
case ThemeMode.DARK:
|
||||
return 'fas fa-moon'
|
||||
case ThemeMode.LIGHT:
|
||||
return 'fas fa-sun'
|
||||
default:
|
||||
return 'fas fa-desktop'
|
||||
}
|
||||
},
|
||||
unreadCount() {
|
||||
return notificationState.unreadCount
|
||||
},
|
||||
showUnreadCount() {
|
||||
return this.unreadCount > 99 ? '99+' : this.unreadCount
|
||||
},
|
||||
shouldShowStats() {
|
||||
return authState.role === 'ADMIN'
|
||||
}
|
||||
},
|
||||
async mounted() {
|
||||
const updateCount = async () => {
|
||||
if (authState.loggedIn) {
|
||||
await fetchUnreadCount()
|
||||
} else {
|
||||
notificationState.unreadCount = 0
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => authState.loggedIn, async () => {
|
||||
await updateCount()
|
||||
})
|
||||
|
||||
const CAT_CACHE_KEY = 'menu-categories'
|
||||
const TAG_CACHE_KEY = 'menu-tags'
|
||||
|
||||
const cachedCategories = localStorage.getItem(CAT_CACHE_KEY)
|
||||
if (cachedCategories) {
|
||||
try {
|
||||
this.categories = JSON.parse(cachedCategories)
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
const cachedTags = localStorage.getItem(TAG_CACHE_KEY)
|
||||
if (cachedTags) {
|
||||
try {
|
||||
this.tags = JSON.parse(cachedTags)
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
this.isLoadingCategory = !cachedCategories
|
||||
this.isLoadingTag = !cachedTags
|
||||
|
||||
const fetchCategories = () => {
|
||||
fetch(`${API_BASE_URL}/api/categories`).then(res => {
|
||||
if (res.ok) {
|
||||
res.json().then(data => {
|
||||
this.categories = data.slice(0, 10)
|
||||
localStorage.setItem(CAT_CACHE_KEY, JSON.stringify(this.categories))
|
||||
})
|
||||
}
|
||||
this.isLoadingCategory = false
|
||||
})
|
||||
}
|
||||
|
||||
const fetchTags = () => {
|
||||
fetch(`${API_BASE_URL}/api/tags?limit=10`).then(res => {
|
||||
if (res.ok) {
|
||||
res.json().then(data => {
|
||||
this.tags = data
|
||||
localStorage.setItem(TAG_CACHE_KEY, JSON.stringify(this.tags))
|
||||
})
|
||||
}
|
||||
this.isLoadingTag = false
|
||||
})
|
||||
}
|
||||
|
||||
if (cachedCategories) {
|
||||
setTimeout(fetchCategories, 1500)
|
||||
} else {
|
||||
fetchCategories()
|
||||
}
|
||||
|
||||
if (cachedTags) {
|
||||
setTimeout(fetchTags, 1500)
|
||||
} else {
|
||||
fetchTags()
|
||||
}
|
||||
|
||||
await updateCount()
|
||||
},
|
||||
methods: {
|
||||
cycleTheme,
|
||||
handleHomeClick() {
|
||||
this.$router.push('/').then(() => {
|
||||
window.location.reload()
|
||||
})
|
||||
},
|
||||
handleItemClick() {
|
||||
if (window.innerWidth <= 768) this.$emit('item-click')
|
||||
},
|
||||
isImageIcon(icon) {
|
||||
if (!icon) return false
|
||||
return /^https?:\/\//.test(icon) || icon.startsWith('/')
|
||||
},
|
||||
gotoCategory(c) {
|
||||
const value = encodeURIComponent(c.id ?? c.name)
|
||||
this.$router
|
||||
.push({ path: '/', query: { category: value } })
|
||||
.then(() => {
|
||||
window.location.reload()
|
||||
})
|
||||
this.handleItemClick()
|
||||
},
|
||||
gotoTag(t) {
|
||||
const value = encodeURIComponent(t.id ?? t.name)
|
||||
this.$router
|
||||
.push({ path: '/', query: { tags: value } })
|
||||
.then(() => {
|
||||
window.location.reload()
|
||||
})
|
||||
this.handleItemClick()
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.menu {
|
||||
position: sticky;
|
||||
top: var(--header-height);
|
||||
width: 200px;
|
||||
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-item {
|
||||
padding: 4px 10px;
|
||||
text-decoration: none;
|
||||
color: var(--menu-text-color);
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.menu-item.selected {
|
||||
font-weight: bold;
|
||||
background-color: var(--menu-selected-background-color);
|
||||
}
|
||||
|
||||
.menu-item-text {
|
||||
font-size: 16px;
|
||||
text-decoration: none;
|
||||
color: var(--menu-text-color);
|
||||
}
|
||||
|
||||
.unread-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
background-color: rgb(255, 102, 102);
|
||||
margin-left: 15px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.unread {
|
||||
color: white;
|
||||
font-size: 9px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.menu-item-icon {
|
||||
margin-right: 10px;
|
||||
opacity: 0.5;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.menu-footer {
|
||||
position: fixed;
|
||||
height: 30px;
|
||||
bottom: 10px;
|
||||
right: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.menu-footer-btn {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
margin-right: 5px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.menu-section {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-weight: bold;
|
||||
opacity: 0.5;
|
||||
padding: 4px 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.section-items {
|
||||
color: var(--menu-text-color);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.section-item {
|
||||
padding: 4px 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.section-item:hover {
|
||||
background-color: var(--menu-selected-background-color);
|
||||
}
|
||||
|
||||
.section-item-text-count {
|
||||
font-size: 12px;
|
||||
color: var(--menu-text-color);
|
||||
opacity: 0.5;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.section-item-text {
|
||||
color: var(--menu-text-color);
|
||||
}
|
||||
|
||||
.section-item-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-right: 5px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.menu-loading-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.menu {
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
box-shadow: 0 0 10px 0 rgba(0, 0, 0, 0.1);
|
||||
left: 10px;
|
||||
border-radius: 20px;
|
||||
border-right: none;
|
||||
height: 400px;
|
||||
top: calc(var(--header-height) + 10px);
|
||||
padding-top: 10px;
|
||||
background-color: var(--background-color-blur);
|
||||
}
|
||||
|
||||
.slide-enter-active,
|
||||
.slide-leave-active {
|
||||
transition:
|
||||
transform 0.3s ease,
|
||||
opacity 0.3s ease,
|
||||
width 0.3s ease;
|
||||
}
|
||||
|
||||
.slide-enter-from,
|
||||
.slide-leave-to {
|
||||
transform: translateX(-100%);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.slide-enter-to,
|
||||
.slide-leave-from {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
185
frontend_nuxt/components/SearchDropdown.vue
Normal file
185
frontend_nuxt/components/SearchDropdown.vue
Normal file
@@ -0,0 +1,185 @@
|
||||
<template>
|
||||
<div class="search-dropdown">
|
||||
<Dropdown ref="dropdown" v-model="selected" :fetch-options="fetchResults" remote menu-class="search-menu"
|
||||
option-class="search-option" :show-search="isMobile" @update:search="keyword = $event" @close="onClose">
|
||||
<template #display="{ setSearch }">
|
||||
<div class="search-input">
|
||||
<i class="search-input-icon fas fa-search"></i>
|
||||
<input class="text-input" v-model="keyword" placeholder="Search" @input="setSearch(keyword)" />
|
||||
</div>
|
||||
</template>
|
||||
<template #option="{ option }">
|
||||
<div class="search-option-item">
|
||||
<i :class="['result-icon', iconMap[option.type] || 'fas fa-question']"></i>
|
||||
<div class="result-body">
|
||||
<div class="result-main" v-html="highlight(option.text)"></div>
|
||||
<div v-if="option.subText" class="result-sub" v-html="highlight(option.subText)"></div>
|
||||
<div v-if="option.extra" class="result-extra" v-html="highlight(option.extra)"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref, watch } from 'vue'
|
||||
import { isMobile } from '~/utils/screen'
|
||||
import { useRouter } from 'vue-router'
|
||||
import Dropdown from '~/components/Dropdown.vue'
|
||||
import { API_BASE_URL } from '~/main'
|
||||
import { stripMarkdown } from '~/utils/markdown'
|
||||
|
||||
export default {
|
||||
name: 'SearchDropdown',
|
||||
components: { Dropdown },
|
||||
emits: ['close'],
|
||||
setup(props, { emit }) {
|
||||
const router = useRouter()
|
||||
const keyword = ref('')
|
||||
const selected = ref(null)
|
||||
const results = ref([])
|
||||
const dropdown = ref(null)
|
||||
|
||||
const toggle = () => {
|
||||
dropdown.value.toggle()
|
||||
}
|
||||
|
||||
const onClose = () => emit('close')
|
||||
|
||||
const fetchResults = async (kw) => {
|
||||
if (!kw) return []
|
||||
const res = await fetch(`${API_BASE_URL}/api/search/global?keyword=${encodeURIComponent(kw)}`)
|
||||
if (!res.ok) return []
|
||||
const data = await res.json()
|
||||
results.value = data.map(r => ({
|
||||
id: r.id,
|
||||
text: r.text,
|
||||
type: r.type,
|
||||
subText: r.subText,
|
||||
extra: r.extra,
|
||||
postId: r.postId
|
||||
}))
|
||||
return results.value
|
||||
}
|
||||
|
||||
const highlight = (text) => {
|
||||
text = stripMarkdown(text)
|
||||
if (!keyword.value) return text
|
||||
const reg = new RegExp(keyword.value, 'gi')
|
||||
const res = text.replace(reg, m => `<span class="highlight">${m}</span>`)
|
||||
return res;
|
||||
}
|
||||
|
||||
const iconMap = {
|
||||
user: 'fas fa-user',
|
||||
post: 'fas fa-file-alt',
|
||||
comment: 'fas fa-comment',
|
||||
category: 'fas fa-folder',
|
||||
tag: 'fas fa-hashtag'
|
||||
}
|
||||
|
||||
watch(selected, val => {
|
||||
if (!val) return
|
||||
const opt = results.value.find(r => r.id === val)
|
||||
if (!opt) return
|
||||
if (opt.type === 'post' || opt.type === 'post_title') {
|
||||
router.push(`/posts/${opt.id}`)
|
||||
} else if (opt.type === 'user') {
|
||||
router.push(`/users/${opt.id}`)
|
||||
} else if (opt.type === 'comment') {
|
||||
if (opt.postId) {
|
||||
router.push(`/posts/${opt.postId}#comment-${opt.id}`)
|
||||
}
|
||||
} else if (opt.type === 'category') {
|
||||
router.push({ path: '/', query: { category: opt.id } }).then(() => {
|
||||
window.location.reload()
|
||||
})
|
||||
} else if (opt.type === 'tag') {
|
||||
router.push({ path: '/', query: { tags: opt.id } }).then(() => {
|
||||
window.location.reload()
|
||||
})
|
||||
}
|
||||
selected.value = null
|
||||
keyword.value = ''
|
||||
})
|
||||
|
||||
return { keyword, selected, fetchResults, highlight, iconMap, isMobile, dropdown, onClose, toggle }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.search-dropdown {
|
||||
margin-top: 20px;
|
||||
width: 500px;
|
||||
}
|
||||
|
||||
.search-mobile-trigger {
|
||||
padding: 10px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.text-input {
|
||||
background-color: var(--menu-background-color);
|
||||
color: var(--text-color);
|
||||
border: none;
|
||||
outline: none;
|
||||
width: 100%;
|
||||
margin-left: 10px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.search-menu {
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.search-dropdown {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.search-option-item {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.search-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 20px;
|
||||
}
|
||||
|
||||
:deep(.highlight) {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.result-icon {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.result-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.result-main {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.result-sub,
|
||||
.result-extra {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
</style>
|
||||
149
frontend_nuxt/components/TagSelect.vue
Normal file
149
frontend_nuxt/components/TagSelect.vue
Normal file
@@ -0,0 +1,149 @@
|
||||
<template>
|
||||
<Dropdown v-model="selected" :fetch-options="fetchTags" multiple placeholder="选择标签" remote
|
||||
:initial-options="mergedOptions">
|
||||
<template #option="{ option }">
|
||||
<div class="option-container">
|
||||
<div class="option-main">
|
||||
<template v-if="option.icon">
|
||||
<img v-if="isImageIcon(option.icon)" :src="option.icon" class="option-icon" :alt="option.name" />
|
||||
<i v-else :class="['option-icon', option.icon]"></i>
|
||||
</template>
|
||||
<span>{{ option.name }}</span>
|
||||
<span class="option-count" v-if="option.count > 0"> x {{ option.count }}</span>
|
||||
</div>
|
||||
<div v-if="option.description" class="option-desc">{{ option.description }}</div>
|
||||
</div>
|
||||
</template>
|
||||
</Dropdown>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { API_BASE_URL, toast } from '~/main'
|
||||
import Dropdown from '~/components/Dropdown.vue'
|
||||
|
||||
export default {
|
||||
name: 'TagSelect',
|
||||
components: { Dropdown },
|
||||
props: {
|
||||
modelValue: { type: Array, default: () => [] },
|
||||
creatable: { type: Boolean, default: false },
|
||||
options: { type: Array, default: () => [] }
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
setup(props, { emit }) {
|
||||
const localTags = ref([])
|
||||
const providedTags = ref(Array.isArray(props.options) ? [...props.options] : [])
|
||||
|
||||
watch(
|
||||
() => props.options,
|
||||
val => {
|
||||
providedTags.value = Array.isArray(val) ? [...val] : []
|
||||
}
|
||||
)
|
||||
|
||||
const mergedOptions = computed(() => {
|
||||
const arr = [...providedTags.value, ...localTags.value]
|
||||
return arr.filter((v, i, a) => a.findIndex(t => t.id === v.id) === i)
|
||||
})
|
||||
|
||||
const isImageIcon = icon => {
|
||||
if (!icon) return false
|
||||
return /^https?:\/\//.test(icon) || icon.startsWith('/')
|
||||
}
|
||||
|
||||
const buildTagsUrl = (kw = '') => {
|
||||
const base = API_BASE_URL || (process.client ? window.location.origin : '');
|
||||
const url = new URL('/api/tags', base);
|
||||
|
||||
if (kw) url.searchParams.set('keyword', kw);
|
||||
url.searchParams.set('limit', '10');
|
||||
|
||||
return url.toString();
|
||||
};
|
||||
|
||||
const fetchTags = async (kw = '') => {
|
||||
const defaultOption = { id: 0, name: '无标签' };
|
||||
|
||||
// 1) 先拼 URL(自动兜底到 window.location.origin)
|
||||
const url = buildTagsUrl(kw);
|
||||
|
||||
// 2) 拉数据
|
||||
let data = [];
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
if (res.ok) data = await res.json();
|
||||
} catch {
|
||||
toast.error('获取标签失败');
|
||||
}
|
||||
|
||||
// 3) 合并、去重、可创建
|
||||
let options = [...data, ...localTags.value];
|
||||
|
||||
if (props.creatable && kw &&
|
||||
!options.some(t => t.name.toLowerCase() === kw.toLowerCase())) {
|
||||
options.push({ id: `__create__:${kw}`, name: `创建"${kw}"` });
|
||||
}
|
||||
|
||||
options = Array.from(new Map(options.map(t => [t.id, t])).values());
|
||||
|
||||
// 4) 最终结果
|
||||
return [defaultOption, ...options];
|
||||
};
|
||||
|
||||
const selected = computed({
|
||||
get: () => props.modelValue,
|
||||
set: v => {
|
||||
if (Array.isArray(v)) {
|
||||
if (v.includes(0)) {
|
||||
emit('update:modelValue', [])
|
||||
return
|
||||
}
|
||||
if (v.length > 2) {
|
||||
toast.error('最多选择两个标签')
|
||||
return
|
||||
}
|
||||
v = v.map(id => {
|
||||
if (typeof id === 'string' && id.startsWith('__create__:')) {
|
||||
const name = id.slice(11)
|
||||
const newId = `__new__:${name}`
|
||||
if (!localTags.value.find(t => t.id === newId)) {
|
||||
localTags.value.push({ id: newId, name })
|
||||
}
|
||||
return newId
|
||||
}
|
||||
return id
|
||||
})
|
||||
}
|
||||
emit('update:modelValue', v)
|
||||
}
|
||||
})
|
||||
|
||||
return { fetchTags, selected, isImageIcon, mergedOptions }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.option-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.option-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.option-desc {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.option-count {
|
||||
font-weight: bold;
|
||||
opacity: 0.4;
|
||||
}
|
||||
</style>
|
||||
159
frontend_nuxt/directives/clickOutside.js
Normal file
159
frontend_nuxt/directives/clickOutside.js
Normal file
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* @file clickOutsideDirective.js
|
||||
* @description 一个用于检测元素外部点击的Vue 3自定义指令。
|
||||
*
|
||||
* @example
|
||||
* // 在 main.js 中全局注册
|
||||
* import { createApp } from 'vue'
|
||||
* import App from './App.vue'
|
||||
* import ClickOutside from './clickOutsideDirective.js'
|
||||
*
|
||||
* const app = createApp(App)
|
||||
* app.directive('click-outside', ClickOutside)
|
||||
* app.mount('#app')
|
||||
*
|
||||
* // 在组件中使用
|
||||
* <div v-click-outside="myMethod">...</div>
|
||||
*
|
||||
* // 排除特定元素
|
||||
* <div v-click-outside:[myExcludedElement]="myMethod">...</div>
|
||||
* <div v-click-outside:[[el1, el2]]="myMethod">...</div>
|
||||
*/
|
||||
|
||||
// 使用一个Map来存储所有指令绑定的元素及其对应的处理器
|
||||
// 键是HTMLElement,值是一个包含处理器和回调函数的对象数组
|
||||
const nodeList = new Map();
|
||||
|
||||
// 检查是否在客户端环境,以避免在SSR(服务器端渲染)时执行
|
||||
const isClient = typeof window !== 'undefined';
|
||||
|
||||
// 在客户端环境中,只设置一次全局的 mousedown 和 mouseup 监听器
|
||||
if (isClient) {
|
||||
let startClick;
|
||||
|
||||
document.addEventListener('mousedown', (e) => (startClick = e));
|
||||
|
||||
document.addEventListener('mouseup', (e) => {
|
||||
// 遍历所有注册的元素和它们的处理器
|
||||
for (const handlers of nodeList.values()) {
|
||||
for (const { documentHandler } of handlers) {
|
||||
// 调用每个处理器,传入 mouseup 和 mousedown 事件
|
||||
documentHandler(e, startClick);
|
||||
}
|
||||
}
|
||||
// 完成后重置 startClick
|
||||
startClick = undefined;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建一个文档事件处理器。
|
||||
* @param {HTMLElement} el - 指令绑定的元素。
|
||||
* @param {import('vue').DirectiveBinding} binding - 指令的绑定对象。
|
||||
* @returns {Function} 返回一个处理函数。
|
||||
*/
|
||||
function createDocumentHandler(el, binding) {
|
||||
let excludes = [];
|
||||
// binding.arg 可以是一个元素或一个元素数组,用于排除不需要触发回调的点击
|
||||
if (Array.isArray(binding.arg)) {
|
||||
excludes = binding.arg;
|
||||
} else if (binding.arg instanceof HTMLElement) {
|
||||
excludes.push(binding.arg);
|
||||
}
|
||||
|
||||
return function (mouseup, mousedown) {
|
||||
// 从组件实例中获取 popper 引用(如果存在),这对于处理下拉菜单、弹窗等很有用
|
||||
const popperRef = binding.instance?.popperRef;
|
||||
const mouseUpTarget = mouseup.target;
|
||||
const mouseDownTarget = mousedown?.target;
|
||||
|
||||
// 检查各种条件,如果满足任一条件,则不执行回调
|
||||
const isBound = !binding || !binding.instance;
|
||||
const isTargetExists = !mouseUpTarget || !mouseDownTarget;
|
||||
const isContainedByEl = el.contains(mouseUpTarget) || el.contains(mouseDownTarget);
|
||||
const isSelf = el === mouseUpTarget;
|
||||
|
||||
// 检查点击是否发生在任何被排除的元素内部
|
||||
const isTargetExcluded =
|
||||
(excludes.length && excludes.some((item) => item?.contains(mouseUpTarget))) ||
|
||||
(excludes.length && excludes.includes(mouseDownTarget));
|
||||
|
||||
// 检查点击是否发生在关联的 popper 元素内部
|
||||
const isContainedByPopper =
|
||||
popperRef &&
|
||||
(popperRef.contains(mouseUpTarget) || popperRef.contains(mouseDownTarget));
|
||||
|
||||
if (
|
||||
isBound ||
|
||||
isTargetExists ||
|
||||
isContainedByEl ||
|
||||
isSelf ||
|
||||
isTargetExcluded ||
|
||||
isContainedByPopper
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果所有检查都通过,说明点击发生在外部,执行指令传入的回调函数
|
||||
binding.value(mouseup, mousedown);
|
||||
};
|
||||
}
|
||||
|
||||
const ClickOutside = {
|
||||
/**
|
||||
* 在绑定元素的 attribute 或事件监听器被应用之前调用。
|
||||
* @param {HTMLElement} el
|
||||
* @param {import('vue').DirectiveBinding} binding
|
||||
*/
|
||||
beforeMount(el, binding) {
|
||||
if (!nodeList.has(el)) {
|
||||
nodeList.set(el, []);
|
||||
}
|
||||
|
||||
nodeList.get(el).push({
|
||||
documentHandler: createDocumentHandler(el, binding),
|
||||
bindingFn: binding.value,
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 在包含组件的 VNode 及其子组件的 VNode 更新后调用。
|
||||
* @param {HTMLElement} el
|
||||
* @param {import('vue').DirectiveBinding} binding
|
||||
*/
|
||||
updated(el, binding) {
|
||||
if (!nodeList.has(el)) {
|
||||
nodeList.set(el, []);
|
||||
}
|
||||
|
||||
const handlers = nodeList.get(el);
|
||||
// 查找旧的回调函数对应的处理器
|
||||
const oldHandlerIndex = handlers.findIndex(
|
||||
(item) => item.bindingFn === binding.oldValue
|
||||
);
|
||||
|
||||
const newHandler = {
|
||||
documentHandler: createDocumentHandler(el, binding),
|
||||
bindingFn: binding.value,
|
||||
};
|
||||
|
||||
if (oldHandlerIndex >= 0) {
|
||||
// 如果找到了,就替换成新的处理器
|
||||
handlers.splice(oldHandlerIndex, 1, newHandler);
|
||||
} else {
|
||||
// 否则,直接添加新的处理器
|
||||
handlers.push(newHandler);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 在绑定元素的父组件卸载后调用。
|
||||
* @param {HTMLElement} el
|
||||
*/
|
||||
unmounted(el) {
|
||||
// 当元素卸载时,从Map中移除它,以进行垃圾回收并防止内存泄漏
|
||||
nodeList.delete(el);
|
||||
},
|
||||
};
|
||||
|
||||
export default ClickOutside;
|
||||
8
frontend_nuxt/main.js
Normal file
8
frontend_nuxt/main.js
Normal file
@@ -0,0 +1,8 @@
|
||||
export const API_DOMAIN = 'https://www.open-isle.com'
|
||||
export const API_PORT = ''
|
||||
export const API_BASE_URL = ''
|
||||
export const GOOGLE_CLIENT_ID = '777830451304-nt8afkkap18gui4f9entcha99unal744.apps.googleusercontent.com'
|
||||
export const GITHUB_CLIENT_ID = 'Ov23liVkO1NPAX5JyWxJ'
|
||||
export const DISCORD_CLIENT_ID = '1394985417044000779'
|
||||
export const TWITTER_CLIENT_ID = 'ZTRTU05KSk9KTTJrTTdrVC1tc1E6MTpjaQ'
|
||||
export const toast = { success: () => {}, error: () => {} }
|
||||
6
frontend_nuxt/nuxt.config.ts
Normal file
6
frontend_nuxt/nuxt.config.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { defineNuxtConfig } from 'nuxt/config'
|
||||
|
||||
export default defineNuxtConfig({
|
||||
ssr: true,
|
||||
css: ['~/assets/global.css', '~/assets/toast.css']
|
||||
})
|
||||
10446
frontend_nuxt/package-lock.json
generated
Normal file
10446
frontend_nuxt/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
14
frontend_nuxt/package.json
Normal file
14
frontend_nuxt/package.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "frontend_nuxt",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "nuxt dev",
|
||||
"build": "nuxt build",
|
||||
"start": "nuxt start"
|
||||
},
|
||||
"dependencies": {
|
||||
"nuxt": "latest",
|
||||
"ldrs": "^1.0.0"
|
||||
}
|
||||
}
|
||||
727
frontend_nuxt/pages/index.vue
Normal file
727
frontend_nuxt/pages/index.vue
Normal file
@@ -0,0 +1,727 @@
|
||||
<template>
|
||||
<div class="home-page">
|
||||
<div v-if="!isMobile" class="search-container">
|
||||
<div class="search-title">一切可能,从此刻启航</div>
|
||||
<div class="search-subtitle">愿你在此遇见灵感与共鸣。若有疑惑,欢迎发问,亦可在知识的海洋中搜寻答案。</div>
|
||||
<SearchDropdown />
|
||||
</div>
|
||||
|
||||
|
||||
<div class="topic-container">
|
||||
<div class="topic-item-container">
|
||||
<div
|
||||
v-for="topic in topics"
|
||||
:key="topic"
|
||||
class="topic-item"
|
||||
:class="{ selected: topic === selectedTopic }"
|
||||
@click="selectedTopic = topic"
|
||||
>
|
||||
{{ topic }}
|
||||
</div>
|
||||
<div class="topic-select-container">
|
||||
<CategorySelect v-model="selectedCategory" :options="categoryOptions" />
|
||||
<TagSelect v-model="selectedTags" :options="tagOptions" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="article-container">
|
||||
<template v-if="selectedTopic === '最新' || selectedTopic === '排行榜' || selectedTopic === '最新回复'">
|
||||
<div class="article-header-container">
|
||||
<div class="header-item main-item">
|
||||
<div class="header-item-text">话题</div>
|
||||
</div>
|
||||
<div class="header-item avatars">
|
||||
<div class="header-item-text">参与人员</div>
|
||||
</div>
|
||||
<div class="header-item comments">
|
||||
<div class="header-item-text">回复</div>
|
||||
</div>
|
||||
<div class="header-item views">
|
||||
<div class="header-item-text">浏览</div>
|
||||
</div>
|
||||
<div class="header-item activity">
|
||||
<div class="header-item-text">活动</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isLoadingPosts && articles.length === 0" class="loading-container">
|
||||
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
|
||||
</div>
|
||||
|
||||
<div v-else-if="articles.length === 0">
|
||||
<div class="no-posts-container">
|
||||
<div class="no-posts-text">暂时没有帖子 :( 点击发帖发送第一篇相关帖子吧!</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="article-item" v-for="article in articles" :key="article.id">
|
||||
<div class="article-main-container">
|
||||
<NuxtLink class="article-item-title main-item" :to="`/posts/${article.id}`">
|
||||
<i v-if="article.pinned" class="fas fa-thumbtack pinned-icon"></i>
|
||||
{{ article.title }}
|
||||
</NuxtLink>
|
||||
<div class="article-item-description main-item">{{ sanitizeDescription(article.description) }}</div>
|
||||
<div class="article-info-container main-item">
|
||||
<ArticleCategory :category="article.category" />
|
||||
<ArticleTags :tags="article.tags" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="article-member-avatars-container">
|
||||
<NuxtLink
|
||||
v-for="member in article.members"
|
||||
:key="member.id"
|
||||
class="article-member-avatar-item"
|
||||
:to="`/users/${member.id}`"
|
||||
>
|
||||
<img class="article-member-avatar-item-img" :src="member.avatar" alt="avatar" />
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<div class="article-comments main-info-text">
|
||||
{{ article.comments }}
|
||||
</div>
|
||||
|
||||
<div class="article-views main-info-text">
|
||||
{{ article.views }}
|
||||
</div>
|
||||
|
||||
<div class="article-time main-info-text">
|
||||
{{ article.time }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else-if="selectedTopic === '热门'" class="placeholder-container">
|
||||
热门帖子功能开发中,敬请期待。
|
||||
</div>
|
||||
<div v-else class="placeholder-container">
|
||||
分类浏览功能开发中,敬请期待。
|
||||
</div>
|
||||
<div v-if="isLoadingPosts && articles.length > 0" class="loading-container bottom-loading">
|
||||
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useScrollLoadMore } from '~/utils/loadMore'
|
||||
import { stripMarkdown } from '~/utils/markdown'
|
||||
import { API_BASE_URL } from '~/main'
|
||||
import { getToken } from '~/utils/auth'
|
||||
import TimeManager from '~/utils/time'
|
||||
import CategorySelect from '~/components/CategorySelect.vue'
|
||||
import TagSelect from '~/components/TagSelect.vue'
|
||||
import ArticleTags from '~/components/ArticleTags.vue'
|
||||
import ArticleCategory from '~/components/ArticleCategory.vue'
|
||||
import SearchDropdown from '~/components/SearchDropdown.vue'
|
||||
import { hatch } from 'ldrs'
|
||||
import { isMobile } from '~/utils/screen'
|
||||
if (process.client) {
|
||||
hatch.register()
|
||||
}
|
||||
|
||||
|
||||
export default {
|
||||
name: 'HomePageView',
|
||||
components: {
|
||||
CategorySelect,
|
||||
TagSelect,
|
||||
ArticleTags,
|
||||
ArticleCategory,
|
||||
SearchDropdown
|
||||
},
|
||||
setup() {
|
||||
const route = useRoute()
|
||||
const selectedCategory = ref('')
|
||||
if (route.query.category) {
|
||||
const c = decodeURIComponent(route.query.category)
|
||||
selectedCategory.value = isNaN(c) ? c : Number(c)
|
||||
}
|
||||
const selectedTags = ref([])
|
||||
if (route.query.tags) {
|
||||
const t = Array.isArray(route.query.tags) ? route.query.tags.join(',') : route.query.tags
|
||||
selectedTags.value = t
|
||||
.split(',')
|
||||
.filter(v => v)
|
||||
.map(v => decodeURIComponent(v))
|
||||
.map(v => (isNaN(v) ? v : Number(v)))
|
||||
}
|
||||
const tagOptions = ref([])
|
||||
const categoryOptions = ref([])
|
||||
const isLoadingPosts = ref(false)
|
||||
const topics = ref(['最新回复', '最新', '排行榜' /*, '热门', '类别'*/])
|
||||
const selectedTopic = ref(
|
||||
route.query.view === 'ranking'
|
||||
? '排行榜'
|
||||
: route.query.view === 'latest'
|
||||
? '最新'
|
||||
: '最新回复'
|
||||
)
|
||||
|
||||
const articles = ref([])
|
||||
const page = ref(0)
|
||||
const pageSize = 10
|
||||
const allLoaded = ref(false)
|
||||
|
||||
// Backend now returns comment counts directly
|
||||
|
||||
const loadOptions = async () => {
|
||||
if (selectedCategory.value && !isNaN(selectedCategory.value)) {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE_URL}/api/categories/${selectedCategory.value}`)
|
||||
if (res.ok) {
|
||||
categoryOptions.value = [await res.json()]
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
if (selectedTags.value.length) {
|
||||
const arr = []
|
||||
for (const t of selectedTags.value) {
|
||||
if (!isNaN(t)) {
|
||||
try {
|
||||
const r = await fetch(`${API_BASE_URL}/api/tags/${t}`)
|
||||
if (r.ok) arr.push(await r.json())
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
}
|
||||
tagOptions.value = arr
|
||||
}
|
||||
}
|
||||
|
||||
const buildUrl = () => {
|
||||
let url = `${API_BASE_URL}/api/posts?page=${page.value}&pageSize=${pageSize}`
|
||||
if (selectedCategory.value) {
|
||||
url += `&categoryId=${selectedCategory.value}`
|
||||
}
|
||||
if (selectedTags.value.length) {
|
||||
selectedTags.value.forEach(t => {
|
||||
url += `&tagIds=${t}`
|
||||
})
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
const buildRankUrl = () => {
|
||||
let url = `${API_BASE_URL}/api/posts/ranking?page=${page.value}&pageSize=${pageSize}`
|
||||
if (selectedCategory.value) {
|
||||
url += `&categoryId=${selectedCategory.value}`
|
||||
}
|
||||
if (selectedTags.value.length) {
|
||||
selectedTags.value.forEach(t => {
|
||||
url += `&tagIds=${t}`
|
||||
})
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
const buildReplyUrl = () => {
|
||||
let url = `${API_BASE_URL}/api/posts/latest-reply?page=${page.value}&pageSize=${pageSize}`
|
||||
if (selectedCategory.value) {
|
||||
url += `&categoryId=${selectedCategory.value}`
|
||||
}
|
||||
if (selectedTags.value.length) {
|
||||
selectedTags.value.forEach(t => {
|
||||
url += `&tagIds=${t}`
|
||||
})
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
const fetchPosts = async (reset = false) => {
|
||||
if (reset) {
|
||||
page.value = 0
|
||||
allLoaded.value = false
|
||||
articles.value = []
|
||||
}
|
||||
if (isLoadingPosts.value || allLoaded.value) return
|
||||
try {
|
||||
isLoadingPosts.value = true
|
||||
const token = getToken()
|
||||
const res = await fetch(buildUrl(), {
|
||||
headers: {
|
||||
Authorization: token ? `Bearer ${token}` : ''
|
||||
}
|
||||
})
|
||||
isLoadingPosts.value = false
|
||||
if (!res.ok) return
|
||||
const data = await res.json()
|
||||
articles.value.push(
|
||||
...data.map(p => ({
|
||||
id: p.id,
|
||||
title: p.title,
|
||||
description: p.content,
|
||||
category: p.category,
|
||||
tags: p.tags || [],
|
||||
members: (p.participants || []).map(m => ({ id: m.id, avatar: m.avatar })),
|
||||
comments: p.commentCount,
|
||||
views: p.views,
|
||||
time: TimeManager.format(p.createdAt),
|
||||
pinned: !!p.pinnedAt
|
||||
}))
|
||||
)
|
||||
if (data.length < pageSize) {
|
||||
allLoaded.value = true
|
||||
} else {
|
||||
page.value += 1
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchRanking = async (reset = false) => {
|
||||
if (reset) {
|
||||
page.value = 0
|
||||
allLoaded.value = false
|
||||
articles.value = []
|
||||
}
|
||||
if (isLoadingPosts.value || allLoaded.value) return
|
||||
try {
|
||||
isLoadingPosts.value = true
|
||||
const res = await fetch(buildRankUrl())
|
||||
isLoadingPosts.value = false
|
||||
if (!res.ok) return
|
||||
const data = await res.json()
|
||||
articles.value.push(
|
||||
...data.map(p => ({
|
||||
id: p.id,
|
||||
title: p.title,
|
||||
description: p.content,
|
||||
category: p.category,
|
||||
tags: p.tags || [],
|
||||
members: (p.participants || []).map(m => ({ id: m.id, avatar: m.avatar })),
|
||||
comments: p.commentCount,
|
||||
views: p.views,
|
||||
time: TimeManager.format(p.createdAt),
|
||||
pinned: !!p.pinnedAt
|
||||
}))
|
||||
)
|
||||
if (data.length < pageSize) {
|
||||
allLoaded.value = true
|
||||
} else {
|
||||
page.value += 1
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchLatestReply = async (reset = false) => {
|
||||
if (reset) {
|
||||
page.value = 0
|
||||
allLoaded.value = false
|
||||
articles.value = []
|
||||
}
|
||||
if (isLoadingPosts.value || allLoaded.value) return
|
||||
try {
|
||||
isLoadingPosts.value = true
|
||||
const res = await fetch(buildReplyUrl())
|
||||
isLoadingPosts.value = false
|
||||
if (!res.ok) return
|
||||
const data = await res.json()
|
||||
articles.value.push(
|
||||
...data.map(p => ({
|
||||
id: p.id,
|
||||
title: p.title,
|
||||
description: p.content,
|
||||
category: p.category,
|
||||
tags: p.tags || [],
|
||||
members: (p.participants || []).map(m => ({ id: m.id, avatar: m.avatar })),
|
||||
comments: p.commentCount,
|
||||
views: p.views,
|
||||
time: TimeManager.format(p.lastReplyAt || p.createdAt),
|
||||
pinned: !!p.pinnedAt
|
||||
}))
|
||||
)
|
||||
if (data.length < pageSize) {
|
||||
allLoaded.value = true
|
||||
} else {
|
||||
page.value += 1
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchContent = async (reset = false) => {
|
||||
if (selectedTopic.value === '排行榜') {
|
||||
fetchRanking(reset)
|
||||
} else if (selectedTopic.value === '最新回复') {
|
||||
fetchLatestReply(reset)
|
||||
} else {
|
||||
fetchPosts(reset)
|
||||
}
|
||||
}
|
||||
|
||||
useScrollLoadMore(fetchContent)
|
||||
|
||||
onMounted(async () => {
|
||||
fetchContent()
|
||||
await loadOptions()
|
||||
})
|
||||
|
||||
watch([selectedCategory, selectedTags], () => {
|
||||
fetchContent(true)
|
||||
})
|
||||
|
||||
watch(selectedTopic, () => {
|
||||
fetchContent(true)
|
||||
})
|
||||
|
||||
const sanitizeDescription = (text) => stripMarkdown(text)
|
||||
|
||||
return { topics, selectedTopic, articles, sanitizeDescription, isLoadingPosts, selectedCategory, selectedTags, tagOptions, categoryOptions, isMobile }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.home-page {
|
||||
background-color: var(--background-color);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
container-type: inline-size;
|
||||
container-name: home-page;
|
||||
}
|
||||
|
||||
.search-container {
|
||||
margin-top: 100px;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.search-title {
|
||||
font-size: 32px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.search-subtitle {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
|
||||
.loading-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 200px;
|
||||
}
|
||||
|
||||
.bottom-loading {
|
||||
height: 100px;
|
||||
}
|
||||
|
||||
.no-posts-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 200px;
|
||||
}
|
||||
|
||||
.no-posts-text {
|
||||
font-size: 14px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.topic-container {
|
||||
position: sticky;
|
||||
top: calc(var(--header-height) + 1px);
|
||||
z-index: 10;
|
||||
background-color: var(--background-color-blur);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.topic-item-container {
|
||||
margin-left: 20px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.topic-select-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.topic-item {
|
||||
padding: 2px 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.topic-item.selected {
|
||||
color: var(--primary-color);
|
||||
border-bottom: 2px solid var(--primary-color);
|
||||
}
|
||||
|
||||
.article-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding-bottom: 100px;
|
||||
}
|
||||
|
||||
.article-header-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
color: gray;
|
||||
border-bottom: 1px solid var(--normal-border-color);
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.article-item {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
border-bottom: 1px solid var(--normal-border-color);
|
||||
}
|
||||
|
||||
|
||||
.article-main-container,
|
||||
.header-item.main-item {
|
||||
width: calc(60% - 20px);
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
/* .article-member-avatars-container,
|
||||
.header-item.avatars, */
|
||||
.article-comments,
|
||||
.header-item.comments,
|
||||
.article-views,
|
||||
.header-item.views,
|
||||
.article-time,
|
||||
.header-item.activity {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.article-member-avatars-container,
|
||||
.header-item.avatars {
|
||||
width: 20%;
|
||||
}
|
||||
|
||||
.article-comments,
|
||||
.header-item.comments {
|
||||
width: 5%;
|
||||
}
|
||||
|
||||
.article-views,
|
||||
.header-item.views {
|
||||
width: 5%;
|
||||
}
|
||||
|
||||
.article-time,
|
||||
.header-item.activity {
|
||||
width: 10%;
|
||||
}
|
||||
|
||||
.article-item-title {
|
||||
margin-top: 10px;
|
||||
font-size: 20px;
|
||||
text-decoration: none;
|
||||
color: var(--text-color);
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.article-item-title:hover {
|
||||
color: var(--primary-color);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.pinned-icon {
|
||||
margin-right: 4px;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.article-item-description {
|
||||
margin-top: 10px;
|
||||
font-size: 14px;
|
||||
color: gray;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.article-info-container {
|
||||
margin-top: 10px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.article-tags-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.article-tag-item {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.article-main-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.article-member-avatars-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
margin-left: 20px;
|
||||
}
|
||||
|
||||
.article-member-avatar-item {
|
||||
width: 25px;
|
||||
height: 25px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.article-member-avatar-item-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.placeholder-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 200px;
|
||||
font-size: 16px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.main-info-text {
|
||||
font-size: 14px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
@container home-page (max-width: 900px) {
|
||||
.article-main-container,
|
||||
.header-item.main-item {
|
||||
width: calc(70% - 20px);
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.article-member-avatars-container,
|
||||
.header-item.avatars {
|
||||
width: 10%;
|
||||
}
|
||||
|
||||
.article-comments,
|
||||
.header-item.comments {
|
||||
width: 5%;
|
||||
}
|
||||
|
||||
.article-views,
|
||||
.header-item.views {
|
||||
width: 5%;
|
||||
}
|
||||
|
||||
.article-time,
|
||||
.header-item.activity {
|
||||
width: 10%;
|
||||
}
|
||||
.article-member-avatar-item:nth-child(n+4) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@container home-page (max-width: 768px) {
|
||||
.article-main-container,
|
||||
.header-item.main-item {
|
||||
width: calc(70% - 20px);
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.article-member-avatars-container,
|
||||
.header-item.avatars {
|
||||
width: 10%;
|
||||
}
|
||||
|
||||
.article-comments,
|
||||
.header-item.comments {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.article-views,
|
||||
.header-item.views {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.article-time,
|
||||
.header-item.activity {
|
||||
width: 10%;
|
||||
margin-right: 3%;
|
||||
}
|
||||
|
||||
.article-header-container {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.article-member-avatar-item:nth-child(n+2) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.header-item-text {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.article-item-title {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.article-item-description {
|
||||
margin-top: 2px;
|
||||
font-size: 10px;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.main-info-text {
|
||||
font-size: 10px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.topic-container {
|
||||
position: initial;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
5
frontend_nuxt/plugins/click-outside.ts
Normal file
5
frontend_nuxt/plugins/click-outside.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import ClickOutside from '~/directives/clickOutside.js'
|
||||
|
||||
export default defineNuxtPlugin((nuxtApp) => {
|
||||
nuxtApp.vueApp.directive('click-outside', ClickOutside)
|
||||
})
|
||||
105
frontend_nuxt/utils/auth.js
Normal file
105
frontend_nuxt/utils/auth.js
Normal file
@@ -0,0 +1,105 @@
|
||||
import { API_BASE_URL } from '~/main'
|
||||
import { reactive } from 'vue'
|
||||
|
||||
const TOKEN_KEY = 'token'
|
||||
const USER_ID_KEY = 'userId'
|
||||
const USERNAME_KEY = 'username'
|
||||
const ROLE_KEY = 'role'
|
||||
|
||||
export const authState = reactive({
|
||||
loggedIn: false,
|
||||
userId: null,
|
||||
username: null,
|
||||
role: null
|
||||
})
|
||||
|
||||
if (process.client) {
|
||||
authState.loggedIn = localStorage.getItem(TOKEN_KEY) !== null && localStorage.getItem(TOKEN_KEY) !== ''
|
||||
authState.userId = localStorage.getItem(USER_ID_KEY)
|
||||
authState.username = localStorage.getItem(USERNAME_KEY)
|
||||
authState.role = localStorage.getItem(ROLE_KEY)
|
||||
}
|
||||
|
||||
export function getToken() {
|
||||
return process.client ? localStorage.getItem(TOKEN_KEY) : null
|
||||
}
|
||||
|
||||
export function setToken(token) {
|
||||
if (process.client) {
|
||||
localStorage.setItem(TOKEN_KEY, token)
|
||||
authState.loggedIn = true
|
||||
}
|
||||
}
|
||||
|
||||
export function clearToken() {
|
||||
if (process.client) {
|
||||
localStorage.removeItem(TOKEN_KEY)
|
||||
clearUserInfo()
|
||||
authState.loggedIn = false
|
||||
}
|
||||
}
|
||||
|
||||
export function setUserInfo({ id, username }) {
|
||||
if (process.client) {
|
||||
authState.userId = id
|
||||
authState.username = username
|
||||
if (arguments[0] && arguments[0].role) {
|
||||
authState.role = arguments[0].role
|
||||
localStorage.setItem(ROLE_KEY, arguments[0].role)
|
||||
}
|
||||
if (id !== undefined && id !== null) localStorage.setItem(USER_ID_KEY, id)
|
||||
if (username) localStorage.setItem(USERNAME_KEY, username)
|
||||
}
|
||||
}
|
||||
|
||||
export function clearUserInfo() {
|
||||
if (process.client) {
|
||||
localStorage.removeItem(USER_ID_KEY)
|
||||
localStorage.removeItem(USERNAME_KEY)
|
||||
localStorage.removeItem(ROLE_KEY)
|
||||
authState.userId = null
|
||||
authState.username = null
|
||||
authState.role = null
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchCurrentUser() {
|
||||
const token = getToken()
|
||||
if (!token) return null
|
||||
try {
|
||||
const res = await fetch(`${API_BASE_URL}/api/users/me`, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
})
|
||||
if (!res.ok) return null
|
||||
return await res.json()
|
||||
} catch (e) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadCurrentUser() {
|
||||
const user = await fetchCurrentUser()
|
||||
if (user) {
|
||||
setUserInfo({ id: user.id, username: user.username, role: user.role })
|
||||
}
|
||||
return user
|
||||
}
|
||||
|
||||
export function isLogin() {
|
||||
return authState.loggedIn
|
||||
}
|
||||
|
||||
export async function checkToken() {
|
||||
const token = getToken()
|
||||
if (!token) return false
|
||||
try {
|
||||
const res = await fetch(`${API_BASE_URL}/api/auth/check`, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
})
|
||||
authState.loggedIn = res.ok
|
||||
return res.ok
|
||||
} catch (e) {
|
||||
authState.loggedIn = false
|
||||
return false
|
||||
}
|
||||
}
|
||||
38
frontend_nuxt/utils/loadMore.js
Normal file
38
frontend_nuxt/utils/loadMore.js
Normal file
@@ -0,0 +1,38 @@
|
||||
import { ref, onMounted, onUnmounted, onActivated, nextTick } from 'vue'
|
||||
|
||||
export function useScrollLoadMore(loadMore, offset = 50) {
|
||||
const savedScrollTop = ref(0)
|
||||
|
||||
const handleScroll = () => {
|
||||
if (!process.client) return
|
||||
const scrollTop = window.scrollY || document.documentElement.scrollTop
|
||||
const scrollHeight = document.documentElement.scrollHeight
|
||||
const windowHeight = window.innerHeight
|
||||
savedScrollTop.value = scrollTop
|
||||
if (scrollHeight - (scrollTop + windowHeight) <= offset) {
|
||||
loadMore()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (process.client) {
|
||||
window.addEventListener('scroll', handleScroll, { passive: true })
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (process.client) {
|
||||
window.removeEventListener('scroll', handleScroll)
|
||||
}
|
||||
})
|
||||
|
||||
onActivated(() => {
|
||||
if (process.client) {
|
||||
nextTick(() => {
|
||||
window.scrollTo({ top: savedScrollTop.value })
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return { savedScrollTop }
|
||||
}
|
||||
11
frontend_nuxt/utils/markdown.js
Normal file
11
frontend_nuxt/utils/markdown.js
Normal file
@@ -0,0 +1,11 @@
|
||||
export function stripMarkdown(text) {
|
||||
return text ? text.replace(/[#_*`>\-\[\]\(\)!]/g, '') : ''
|
||||
}
|
||||
|
||||
export function stripMarkdownLength(text, length) {
|
||||
const plain = stripMarkdown(text)
|
||||
if (!length || plain.length <= length) {
|
||||
return plain
|
||||
}
|
||||
return plain.slice(0, length) + '...'
|
||||
}
|
||||
48
frontend_nuxt/utils/notification.js
Normal file
48
frontend_nuxt/utils/notification.js
Normal file
@@ -0,0 +1,48 @@
|
||||
import { API_BASE_URL } from '~/main'
|
||||
import { getToken } from './auth'
|
||||
import { reactive } from 'vue'
|
||||
|
||||
export const notificationState = reactive({
|
||||
unreadCount: 0
|
||||
})
|
||||
|
||||
export async function fetchUnreadCount() {
|
||||
try {
|
||||
const token = getToken()
|
||||
if (!token) {
|
||||
notificationState.unreadCount = 0
|
||||
return 0
|
||||
}
|
||||
const res = await fetch(`${API_BASE_URL}/api/notifications/unread-count`, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
})
|
||||
if (!res.ok) {
|
||||
notificationState.unreadCount = 0
|
||||
return 0
|
||||
}
|
||||
const data = await res.json()
|
||||
notificationState.unreadCount = data.count
|
||||
return data.count
|
||||
} catch (e) {
|
||||
notificationState.unreadCount = 0
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
export async function markNotificationsRead(ids) {
|
||||
try {
|
||||
const token = getToken()
|
||||
if (!token || !ids || ids.length === 0) return false
|
||||
const res = await fetch(`${API_BASE_URL}/api/notifications/read`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ ids })
|
||||
})
|
||||
return res.ok
|
||||
} catch (e) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
12
frontend_nuxt/utils/screen.js
Normal file
12
frontend_nuxt/utils/screen.js
Normal file
@@ -0,0 +1,12 @@
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const width = ref(0)
|
||||
|
||||
if (process.client) {
|
||||
width.value = window.innerWidth
|
||||
window.addEventListener('resize', () => {
|
||||
width.value = window.innerWidth
|
||||
})
|
||||
}
|
||||
|
||||
export const isMobile = computed(() => width.value <= 768)
|
||||
64
frontend_nuxt/utils/theme.js
Normal file
64
frontend_nuxt/utils/theme.js
Normal file
@@ -0,0 +1,64 @@
|
||||
import { reactive } from 'vue'
|
||||
import { toast } from '~/main'
|
||||
|
||||
export const ThemeMode = {
|
||||
SYSTEM: 'system',
|
||||
LIGHT: 'light',
|
||||
DARK: 'dark'
|
||||
}
|
||||
|
||||
const THEME_KEY = 'theme-mode'
|
||||
|
||||
export const themeState = reactive({
|
||||
mode: ThemeMode.SYSTEM
|
||||
})
|
||||
|
||||
function apply(mode) {
|
||||
if (!process.client) return
|
||||
const root = document.documentElement
|
||||
if (mode === ThemeMode.SYSTEM) {
|
||||
root.dataset.theme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
||||
} else {
|
||||
root.dataset.theme = mode
|
||||
}
|
||||
}
|
||||
|
||||
export function initTheme() {
|
||||
if (!process.client) return
|
||||
const saved = localStorage.getItem(THEME_KEY)
|
||||
if (saved && Object.values(ThemeMode).includes(saved)) {
|
||||
themeState.mode = saved
|
||||
}
|
||||
apply(themeState.mode)
|
||||
}
|
||||
|
||||
export function setTheme(mode) {
|
||||
if (!process.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
|
||||
const modes = [ThemeMode.SYSTEM, ThemeMode.LIGHT, ThemeMode.DARK]
|
||||
const index = modes.indexOf(themeState.mode)
|
||||
const next = modes[(index + 1) % modes.length]
|
||||
if (next === ThemeMode.SYSTEM) {
|
||||
toast.success('💻 已经切换到系统主题')
|
||||
} else if (next === ThemeMode.LIGHT) {
|
||||
toast.success('🌞 已经切换到明亮主题')
|
||||
} else {
|
||||
toast.success('🌙 已经切换到暗色主题')
|
||||
}
|
||||
setTheme(next)
|
||||
}
|
||||
|
||||
if (process.client && window.matchMedia) {
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
|
||||
if (themeState.mode === ThemeMode.SYSTEM) {
|
||||
apply(ThemeMode.SYSTEM)
|
||||
}
|
||||
})
|
||||
}
|
||||
32
frontend_nuxt/utils/time.js
Normal file
32
frontend_nuxt/utils/time.js
Normal file
@@ -0,0 +1,32 @@
|
||||
export default class TimeManager {
|
||||
static format(input) {
|
||||
const date = new Date(input)
|
||||
if (Number.isNaN(date.getTime())) return ''
|
||||
|
||||
const now = new Date()
|
||||
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||
const startOfDate = new Date(date.getFullYear(), date.getMonth(), date.getDate())
|
||||
const diffDays = Math.floor((startOfToday - startOfDate) / 86400000)
|
||||
|
||||
const hh = date.getHours().toString().padStart(2, '0')
|
||||
const mm = date.getMinutes().toString().padStart(2, '0')
|
||||
const timePart = `${hh}:${mm}`
|
||||
|
||||
if (diffDays === 0) return `今天 ${timePart}`
|
||||
if (diffDays === 1) return `昨天 ${timePart}`
|
||||
if (diffDays === 2) return `前天 ${timePart}`
|
||||
|
||||
const month = date.getMonth() + 1
|
||||
const day = date.getDate()
|
||||
|
||||
if (date.getFullYear() === now.getFullYear()) {
|
||||
return `${month}.${day} ${timePart}`
|
||||
}
|
||||
|
||||
if (date.getFullYear() === now.getFullYear() - 1) {
|
||||
return `去年 ${month}.${day} ${timePart}`
|
||||
}
|
||||
|
||||
return `${date.getFullYear()}.${month}.${day} ${timePart}`
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user