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

View File

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