mirror of
https://github.com/nagisa77/OpenIsle.git
synced 2026-05-08 03:37:28 +08:00
feat: add mobile dropdown page and search
This commit is contained in:
@@ -58,7 +58,10 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
|
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
import { hatch } from 'ldrs'
|
import { hatch } from 'ldrs'
|
||||||
|
import { isMobile } from '../utils/screen'
|
||||||
|
import { registerDropdownStore, removeDropdownStore } from '../utils/mobileDropdown'
|
||||||
hatch.register()
|
hatch.register()
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
@@ -76,7 +79,10 @@ export default {
|
|||||||
},
|
},
|
||||||
emits: ['update:modelValue'],
|
emits: ['update:modelValue'],
|
||||||
setup(props, { emit }) {
|
setup(props, { emit }) {
|
||||||
|
const router = useRouter()
|
||||||
|
const storeId = Math.random().toString(36).substring(2)
|
||||||
const open = ref(false)
|
const open = ref(false)
|
||||||
|
const model = ref(props.modelValue)
|
||||||
const search = ref('')
|
const search = ref('')
|
||||||
const setSearch = (val) => {
|
const setSearch = (val) => {
|
||||||
search.value = val
|
search.value = val
|
||||||
@@ -87,7 +93,18 @@ export default {
|
|||||||
const wrapper = ref(null)
|
const wrapper = ref(null)
|
||||||
|
|
||||||
const toggle = () => {
|
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 = () => {
|
const close = () => {
|
||||||
@@ -96,16 +113,16 @@ export default {
|
|||||||
|
|
||||||
const select = id => {
|
const select = id => {
|
||||||
if (props.multiple) {
|
if (props.multiple) {
|
||||||
const arr = Array.isArray(props.modelValue) ? [...props.modelValue] : []
|
const arr = Array.isArray(model.value) ? [...model.value] : []
|
||||||
const idx = arr.indexOf(id)
|
const idx = arr.indexOf(id)
|
||||||
if (idx > -1) {
|
if (idx > -1) {
|
||||||
arr.splice(idx, 1)
|
arr.splice(idx, 1)
|
||||||
} else {
|
} else {
|
||||||
arr.push(id)
|
arr.push(id)
|
||||||
}
|
}
|
||||||
emit('update:modelValue', arr)
|
model.value = arr
|
||||||
} else {
|
} else {
|
||||||
emit('update:modelValue', id)
|
model.value = id
|
||||||
close()
|
close()
|
||||||
}
|
}
|
||||||
search.value = ''
|
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 => {
|
watch(open, async val => {
|
||||||
if (val) {
|
if (val) {
|
||||||
@@ -163,21 +192,26 @@ export default {
|
|||||||
})
|
})
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
document.addEventListener('click', clickOutside)
|
if (!isMobile.value) {
|
||||||
|
document.addEventListener('click', clickOutside)
|
||||||
|
}
|
||||||
if (!props.remote) {
|
if (!props.remote) {
|
||||||
loadOptions()
|
loadOptions()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
document.removeEventListener('click', clickOutside)
|
if (!isMobile.value) {
|
||||||
|
document.removeEventListener('click', clickOutside)
|
||||||
|
}
|
||||||
|
removeDropdownStore(storeId)
|
||||||
})
|
})
|
||||||
|
|
||||||
const selectedLabels = computed(() => {
|
const selectedLabels = computed(() => {
|
||||||
if (props.multiple) {
|
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] : []
|
return match ? [match] : []
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="isLogin" class="header-content-right">
|
<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">
|
<DropdownMenu ref="userMenu" :items="headerMenuItems">
|
||||||
<template #trigger>
|
<template #trigger>
|
||||||
<div class="avatar-container">
|
<div class="avatar-container">
|
||||||
@@ -24,6 +27,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="header-content-right">
|
<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-main" @click="goToLogin">登录</div>
|
||||||
<div class="header-content-item-secondary" @click="goToSignup">注册</div>
|
<div class="header-content-item-secondary" @click="goToSignup">注册</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -33,8 +39,11 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { authState, clearToken, loadCurrentUser } from '../utils/auth'
|
import { authState, clearToken, loadCurrentUser } from '../utils/auth'
|
||||||
import { watch } from 'vue'
|
import { watch, ref } from 'vue'
|
||||||
import DropdownMenu from './DropdownMenu.vue'
|
import DropdownMenu from './DropdownMenu.vue'
|
||||||
|
import { isMobile } from '../utils/screen'
|
||||||
|
import { registerDropdownStore } from '../utils/mobileDropdown'
|
||||||
|
import { API_BASE_URL } from '../main'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'HeaderComponent',
|
name: 'HeaderComponent',
|
||||||
@@ -54,6 +63,9 @@ export default {
|
|||||||
isLogin() {
|
isLogin() {
|
||||||
return authState.loggedIn
|
return authState.loggedIn
|
||||||
},
|
},
|
||||||
|
isMobile() {
|
||||||
|
return isMobile.value
|
||||||
|
},
|
||||||
headerMenuItems() {
|
headerMenuItems() {
|
||||||
return [
|
return [
|
||||||
{ text: '设置', onClick: this.goToSettings },
|
{ text: '设置', onClick: this.goToSettings },
|
||||||
@@ -120,6 +132,23 @@ export default {
|
|||||||
goToLogout() {
|
goToLogout() {
|
||||||
clearToken()
|
clearToken()
|
||||||
this.$router.push('/login')
|
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;
|
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 {
|
.avatar-container {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import NotFoundPageView from '../views/NotFoundPageView.vue'
|
|||||||
import GithubCallbackPageView from '../views/GithubCallbackPageView.vue'
|
import GithubCallbackPageView from '../views/GithubCallbackPageView.vue'
|
||||||
import DiscordCallbackPageView from '../views/DiscordCallbackPageView.vue'
|
import DiscordCallbackPageView from '../views/DiscordCallbackPageView.vue'
|
||||||
import TwitterCallbackPageView from '../views/TwitterCallbackPageView.vue'
|
import TwitterCallbackPageView from '../views/TwitterCallbackPageView.vue'
|
||||||
|
import MobileDropdownPageView from '../views/MobileDropdownPageView.vue'
|
||||||
|
|
||||||
const routes = [
|
const routes = [
|
||||||
{
|
{
|
||||||
@@ -86,6 +87,11 @@ const routes = [
|
|||||||
name: 'twitter-callback',
|
name: 'twitter-callback',
|
||||||
component: TwitterCallbackPageView
|
component: TwitterCallbackPageView
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/mobile-dropdown/:id',
|
||||||
|
name: 'mobile-dropdown',
|
||||||
|
component: MobileDropdownPageView
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/404',
|
path: '/404',
|
||||||
name: 'not-found',
|
name: 'not-found',
|
||||||
|
|||||||
15
open-isle-cli/src/utils/mobileDropdown.js
Normal file
15
open-isle-cli/src/utils/mobileDropdown.js
Normal 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]
|
||||||
|
}
|
||||||
156
open-isle-cli/src/views/MobileDropdownPageView.vue
Normal file
156
open-isle-cli/src/views/MobileDropdownPageView.vue
Normal 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>
|
||||||
Reference in New Issue
Block a user