feat(ui): add reusable dropdown menu

This commit is contained in:
Tim
2025-07-11 12:32:59 +08:00
parent d8f420d0a6
commit 004202ce44
3 changed files with 123 additions and 37 deletions

View File

@@ -0,0 +1,86 @@
<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">
<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 {
position: absolute;
top: 100%;
right: 0;
background-color: var(--menu-background-color);
border: 1px solid #ddd;
border-radius: 4px;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
z-index: 10;
}
.dropdown-item {
padding: 8px 16px;
white-space: nowrap;
}
.dropdown-item:hover {
background-color: var(--menu-selected-background-color);
}
</style>

View File

@@ -13,15 +13,14 @@
</div>
<div v-if="isLogin" class="header-content-right">
<div class="avatar-container" @click="toggleDropdown">
<img class="avatar-img" :src="avatar" alt="avatar">
<i class="fas fa-caret-down dropdown-icon"></i>
<div v-if="dropdownVisible" class="dropdown-menu">
<div class="dropdown-item" @click="goToSettings">设置</div>
<div class="dropdown-item" @click="goToProfile">个人主页</div>
<div class="dropdown-item" @click="goToLogout">退出</div>
</div>
</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">
@@ -35,9 +34,11 @@
<script>
import { authState, clearToken, loadCurrentUser } from '../utils/auth'
import { watch } from 'vue'
import DropdownMenu from './DropdownMenu.vue'
export default {
name: 'HeaderComponent',
components: { DropdownMenu },
props: {
showMenuBtn: {
type: Boolean,
@@ -46,13 +47,19 @@ export default {
},
data() {
return {
dropdownVisible: false,
avatar: ''
}
},
computed: {
isLogin() {
return authState.loggedIn
},
headerMenuItems() {
return [
{ text: '设置', onClick: this.goToSettings },
{ text: '个人主页', onClick: this.goToProfile },
{ text: '退出', onClick: this.goToLogout }
]
}
},
async mounted() {
@@ -72,25 +79,12 @@ export default {
})
watch(() => this.$route.fullPath, () => {
this.dropdownVisible = false
if (this.$refs.userMenu) this.$refs.userMenu.close()
})
this.onClickOutside = (e) => {
if (!this.$el.contains(e.target)) {
this.dropdownVisible = false
}
}
document.addEventListener('click', this.onClickOutside)
},
beforeUnmount() {
document.removeEventListener('click', this.onClickOutside)
},
methods: {
toggleDropdown() {
this.dropdownVisible = !this.dropdownVisible
},
goToHome() {
this.$router.push('/')
},
@@ -99,7 +93,6 @@ export default {
},
goToSettings() {
this.$router.push('/settings')
this.dropdownVisible = false
},
async goToProfile() {
if (!authState.loggedIn) {
@@ -116,14 +109,12 @@ export default {
if (id) {
this.$router.push(`/users/${id}`)
}
this.dropdownVisible = false
},
goToSignup() {
this.$router.push('/signup')
},
goToLogout() {
clearToken()
this.dropdownVisible = false
this.$router.push('/login')
}
}

View File

@@ -27,14 +27,11 @@
<i class="fas fa-user-minus"></i>
取消订阅
</div>
<div class="article-arrow-button">
<i class="fas fa-arrow-right"></i>
通过审核
</div>
<div class="article-reject-button">
<i class="fas fa-times"></i>
驳回
</div>
<DropdownMenu :items="reviewMenuItems">
<template #trigger>
<i class="fas fa-ellipsis-vertical action-menu-icon"></i>
</template>
</DropdownMenu>
</div>
</div>
@@ -106,6 +103,7 @@ import BaseTimeline from '../components/BaseTimeline.vue'
import ArticleTags from '../components/ArticleTags.vue'
import ArticleCategory from '../components/ArticleCategory.vue'
import ReactionsGroup from '../components/ReactionsGroup.vue'
import DropdownMenu from '../components/DropdownMenu.vue'
import { renderMarkdown } from '../utils/markdown'
import { API_BASE_URL, toast } from '../main'
import { getToken } from '../utils/auth'
@@ -116,7 +114,7 @@ hatch.register()
export default {
name: 'PostPageView',
components: { CommentItem, CommentEditor, BaseTimeline, ArticleTags, ArticleCategory, ReactionsGroup },
components: { CommentItem, CommentEditor, BaseTimeline, ArticleTags, ArticleCategory, ReactionsGroup, DropdownMenu },
setup() {
const route = useRoute()
const postId = route.params.id
@@ -135,6 +133,10 @@ export default {
const postItems = ref([])
const mainContainer = ref(null)
const currentIndex = ref(1)
const reviewMenuItems = [
{ text: '通过审核', onClick: () => {} },
{ text: '驳回', color: 'red', onClick: () => {} }
]
const gatherPostItems = () => {
const items = []
@@ -336,6 +338,7 @@ export default {
currentIndex,
totalPosts,
postReactions,
reviewMenuItems,
postId,
postComment,
onSliderInput,
@@ -522,7 +525,7 @@ export default {
cursor: pointer;
}
.article-reject-button {
.article-reject-button {
background-color: red;
color: white;
border: 1px solid red;
@@ -532,6 +535,12 @@ export default {
cursor: pointer;
}
.action-menu-icon {
cursor: pointer;
font-size: 18px;
padding: 5px;
}
.article-info-container {
display: flex;
flex-direction: row;