feat: add initial Nuxt frontend with SSR

This commit is contained in:
Tim
2025-08-07 19:18:42 +08:00
parent 925973b134
commit cfdd257b9a
34 changed files with 14165 additions and 0 deletions

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>