feat: add mobile dropdown page and search

This commit is contained in:
Tim
2025-07-21 09:15:54 +08:00
parent 8c253f5a60
commit 7a59f413b5
5 changed files with 263 additions and 9 deletions

View File

@@ -58,7 +58,10 @@
<script>
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
import { useRouter } from 'vue-router'
import { hatch } from 'ldrs'
import { isMobile } from '../utils/screen'
import { registerDropdownStore, removeDropdownStore } from '../utils/mobileDropdown'
hatch.register()
export default {
@@ -76,7 +79,10 @@ export default {
},
emits: ['update:modelValue'],
setup(props, { emit }) {
const router = useRouter()
const storeId = Math.random().toString(36).substring(2)
const open = ref(false)
const model = ref(props.modelValue)
const search = ref('')
const setSearch = (val) => {
search.value = val
@@ -87,7 +93,18 @@ export default {
const wrapper = ref(null)
const toggle = () => {
open.value = !open.value
if (isMobile.value) {
registerDropdownStore(storeId, {
value: model,
multiple: props.multiple,
fetchOptions: props.fetchOptions,
remote: props.remote,
showSearch: props.showSearch
})
router.push(`/mobile-dropdown/${storeId}`)
} else {
open.value = !open.value
}
}
const close = () => {
@@ -96,16 +113,16 @@ export default {
const select = id => {
if (props.multiple) {
const arr = Array.isArray(props.modelValue) ? [...props.modelValue] : []
const arr = Array.isArray(model.value) ? [...model.value] : []
const idx = arr.indexOf(id)
if (idx > -1) {
arr.splice(idx, 1)
} else {
arr.push(id)
}
emit('update:modelValue', arr)
model.value = arr
} else {
emit('update:modelValue', id)
model.value = id
close()
}
search.value = ''
@@ -145,6 +162,18 @@ export default {
}
}
)
watch(
() => props.modelValue,
val => {
model.value = val
}
)
watch(
() => model.value,
val => {
emit('update:modelValue', val)
}
)
watch(open, async val => {
if (val) {
@@ -163,21 +192,26 @@ export default {
})
onMounted(() => {
document.addEventListener('click', clickOutside)
if (!isMobile.value) {
document.addEventListener('click', clickOutside)
}
if (!props.remote) {
loadOptions()
}
})
onBeforeUnmount(() => {
document.removeEventListener('click', clickOutside)
if (!isMobile.value) {
document.removeEventListener('click', clickOutside)
}
removeDropdownStore(storeId)
})
const selectedLabels = computed(() => {
if (props.multiple) {
return options.value.filter(o => (props.modelValue || []).includes(o.id))
return options.value.filter(o => (model.value || []).includes(o.id))
}
const match = options.value.find(o => o.id === props.modelValue)
const match = options.value.find(o => o.id === model.value)
return match ? [match] : []
})

View File

@@ -13,6 +13,9 @@
</div>
<div v-if="isLogin" class="header-content-right">
<button v-if="isMobile" class="mobile-search-btn" @click="openMobileSearch">
<i class="fas fa-search"></i>
</button>
<DropdownMenu ref="userMenu" :items="headerMenuItems">
<template #trigger>
<div class="avatar-container">
@@ -24,6 +27,9 @@
</div>
<div v-else class="header-content-right">
<button v-if="isMobile" class="mobile-search-btn" @click="openMobileSearch">
<i class="fas fa-search"></i>
</button>
<div class="header-content-item-main" @click="goToLogin">登录</div>
<div class="header-content-item-secondary" @click="goToSignup">注册</div>
</div>
@@ -33,8 +39,11 @@
<script>
import { authState, clearToken, loadCurrentUser } from '../utils/auth'
import { watch } from 'vue'
import { watch, ref } from 'vue'
import DropdownMenu from './DropdownMenu.vue'
import { isMobile } from '../utils/screen'
import { registerDropdownStore } from '../utils/mobileDropdown'
import { API_BASE_URL } from '../main'
export default {
name: 'HeaderComponent',
@@ -54,6 +63,9 @@ export default {
isLogin() {
return authState.loggedIn
},
isMobile() {
return isMobile.value
},
headerMenuItems() {
return [
{ text: '设置', onClick: this.goToSettings },
@@ -120,6 +132,23 @@ export default {
goToLogout() {
clearToken()
this.$router.push('/login')
},
async openMobileSearch() {
const id = Math.random().toString(36).substring(2)
registerDropdownStore(id, {
value: ref(null),
multiple: false,
remote: true,
showSearch: true,
async fetchOptions(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()
return data.map(r => ({ id: r.id, name: r.text }))
}
})
this.$router.push(`/mobile-dropdown/${id}`)
}
}
}
@@ -200,6 +229,20 @@ export default {
cursor: pointer;
}
.mobile-search-btn {
background: none;
border: none;
color: inherit;
font-size: 20px;
cursor: pointer;
}
@media (min-width: 769px) {
.mobile-search-btn {
display: none;
}
}
.avatar-container {
position: relative;
display: flex;

View File

@@ -14,6 +14,7 @@ import NotFoundPageView from '../views/NotFoundPageView.vue'
import GithubCallbackPageView from '../views/GithubCallbackPageView.vue'
import DiscordCallbackPageView from '../views/DiscordCallbackPageView.vue'
import TwitterCallbackPageView from '../views/TwitterCallbackPageView.vue'
import MobileDropdownPageView from '../views/MobileDropdownPageView.vue'
const routes = [
{
@@ -86,6 +87,11 @@ const routes = [
name: 'twitter-callback',
component: TwitterCallbackPageView
},
{
path: '/mobile-dropdown/:id',
name: 'mobile-dropdown',
component: MobileDropdownPageView
},
{
path: '/404',
name: 'not-found',

View File

@@ -0,0 +1,15 @@
import { reactive } from 'vue'
const stores = reactive({})
export function registerDropdownStore(id, store) {
stores[id] = store
}
export function getDropdownStore(id) {
return stores[id]
}
export function removeDropdownStore(id) {
delete stores[id]
}

View File

@@ -0,0 +1,156 @@
<template>
<div class="mobile-dropdown-page">
<div class="mobile-dropdown-header">
<i class="fas fa-arrow-left back-icon" @click="goBack"></i>
<div v-if="store && store.showSearch" class="search-container">
<input
type="text"
v-model="search"
placeholder="搜索"
@input="onSearch"
/>
</div>
</div>
<div class="options" ref="scrollEl">
<div v-if="loading" class="loading-container">
<l-hatch size="28" 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="['option', { selected: isSelected(o.id) }]"
>
<span>{{ o.name }}</span>
</div>
</template>
</div>
</div>
</template>
<script>
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { getDropdownStore, removeDropdownStore } from '../utils/mobileDropdown'
import { hatch } from 'ldrs'
hatch.register()
export default {
name: 'MobileDropdownPageView',
setup() {
const router = useRouter()
const route = useRoute()
const id = route.params.id
const store = getDropdownStore(id)
if (!store) {
router.back()
return {}
}
const options = ref([])
const search = ref('')
const loading = ref(false)
const loadOptions = async (kw = '') => {
try {
loading.value = true
const res = await store.fetchOptions(kw)
options.value = Array.isArray(res) ? res : []
} finally {
loading.value = false
}
}
const filteredOptions = computed(() => {
if (store.remote) return options.value
if (!search.value) return options.value
return options.value.filter(o => o.name.toLowerCase().includes(search.value.toLowerCase()))
})
const isSelected = id => {
if (store.multiple) {
return Array.isArray(store.value.value) && store.value.value.includes(id)
}
return store.value.value === id
}
const select = id => {
if (store.multiple) {
const arr = Array.isArray(store.value.value) ? [...store.value.value] : []
const idx = arr.indexOf(id)
if (idx > -1) arr.splice(idx, 1)
else arr.push(id)
store.value.value = arr
} else {
store.value.value = id
router.back()
}
}
const onSearch = async () => {
if (store.remote) {
await loadOptions(search.value)
}
}
const goBack = () => {
router.back()
}
onMounted(async () => {
await loadOptions()
})
onBeforeUnmount(() => {
removeDropdownStore(id)
})
return { store, options, search, loading, filteredOptions, isSelected, select, onSearch, goBack }
}
}
</script>
<style scoped>
.mobile-dropdown-page {
display: flex;
flex-direction: column;
height: 100vh;
background-color: var(--background-color);
}
.mobile-dropdown-header {
display: flex;
align-items: center;
padding: 10px;
border-bottom: 1px solid var(--normal-border-color);
}
.back-icon {
font-size: 20px;
margin-right: 10px;
}
.search-container {
flex: 1;
}
.search-container input {
width: 100%;
padding: 6px 8px;
border-radius: 4px;
border: 1px solid var(--normal-border-color);
background-color: var(--menu-background-color);
color: var(--text-color);
}
.options {
flex: 1;
overflow-y: auto;
}
.option {
padding: 12px 16px;
border-bottom: 1px solid var(--normal-border-color);
}
.option.selected {
background-color: var(--menu-selected-background-color);
}
.loading-container {
display: flex;
justify-content: center;
padding: 20px 0;
}
</style>