Add gallery search and account menu
This commit is contained in:
@@ -1,19 +1,26 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||||
import { NButton, NSpace } from 'naive-ui'
|
import { NIcon, NSpace } from 'naive-ui'
|
||||||
|
import { Logout, Settings, Shield, User } from '@vicons/tabler'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { RouterLink, useRoute } from 'vue-router'
|
import { RouterLink, useRoute, useRouter } from 'vue-router'
|
||||||
|
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
const isMapRoute = computed(() => route.name === 'map')
|
const isMapRoute = computed(() => route.name === 'map')
|
||||||
|
|
||||||
const headerHidden = ref(false)
|
const headerHidden = ref(false)
|
||||||
const headerPinnedOpen = ref(false)
|
const headerPinnedOpen = ref(false)
|
||||||
|
const accountCardOpen = ref(false)
|
||||||
const activeNavClass = 'bg-teal-100 text-teal-800 ring-1 ring-teal-200'
|
const activeNavClass = 'bg-teal-100 text-teal-800 ring-1 ring-teal-200'
|
||||||
const inactiveNavClass = 'text-slate-600 hover:bg-teal-50 hover:text-teal-800'
|
const inactiveNavClass = 'text-slate-600 hover:bg-teal-50 hover:text-teal-800'
|
||||||
|
|
||||||
|
const displayUsername = computed(() => authStore.profile?.username || authStore.user?.email || 'OpenCloud 用户')
|
||||||
|
const userEmail = computed(() => authStore.user?.email || '未绑定邮箱')
|
||||||
|
|
||||||
let lastScrollY = 0
|
let lastScrollY = 0
|
||||||
|
let accountCloseTimer: number | null = null
|
||||||
const HIDE_HEADER_EVENT = 'opencloud:hide-header'
|
const HIDE_HEADER_EVENT = 'opencloud:hide-header'
|
||||||
|
|
||||||
function showHeader(pin = false) {
|
function showHeader(pin = false) {
|
||||||
@@ -69,11 +76,16 @@ onMounted(() => {
|
|||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
window.removeEventListener('scroll', handleScroll)
|
window.removeEventListener('scroll', handleScroll)
|
||||||
window.removeEventListener(HIDE_HEADER_EVENT, forceHideHeader as EventListener)
|
window.removeEventListener(HIDE_HEADER_EVENT, forceHideHeader as EventListener)
|
||||||
|
if (accountCloseTimer !== null) {
|
||||||
|
window.clearTimeout(accountCloseTimer)
|
||||||
|
accountCloseTimer = null
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
function syncHeaderForRoute() {
|
function syncHeaderForRoute() {
|
||||||
headerPinnedOpen.value = false
|
headerPinnedOpen.value = false
|
||||||
headerHidden.value = false
|
headerHidden.value = false
|
||||||
|
accountCardOpen.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(() => route.fullPath, () => {
|
watch(() => route.fullPath, () => {
|
||||||
@@ -87,6 +99,32 @@ watch(isMapRoute, () => {
|
|||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
syncHeaderForRoute()
|
syncHeaderForRoute()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function openAccountCard() {
|
||||||
|
if (accountCloseTimer !== null) {
|
||||||
|
window.clearTimeout(accountCloseTimer)
|
||||||
|
accountCloseTimer = null
|
||||||
|
}
|
||||||
|
accountCardOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeAccountCard() {
|
||||||
|
if (accountCloseTimer !== null) {
|
||||||
|
window.clearTimeout(accountCloseTimer)
|
||||||
|
}
|
||||||
|
accountCloseTimer = window.setTimeout(() => {
|
||||||
|
accountCardOpen.value = false
|
||||||
|
accountCloseTimer = null
|
||||||
|
}, 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleLogout() {
|
||||||
|
await authStore.logout()
|
||||||
|
closeAccountCard()
|
||||||
|
if (route.meta.requiresAuth) {
|
||||||
|
router.push({ name: 'map' })
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -155,25 +193,92 @@ onMounted(() => {
|
|||||||
</RouterLink>
|
</RouterLink>
|
||||||
|
|
||||||
<template v-if="authStore.isLoggedIn">
|
<template v-if="authStore.isLoggedIn">
|
||||||
<RouterLink
|
<div
|
||||||
to="/profile"
|
class="relative"
|
||||||
class="no-underline"
|
@mouseenter="openAccountCard"
|
||||||
|
@mouseleave="closeAccountCard"
|
||||||
|
@focusin="openAccountCard"
|
||||||
|
@focusout="closeAccountCard"
|
||||||
>
|
>
|
||||||
<span
|
<button
|
||||||
class="inline-flex h-8 max-w-[7.5rem] items-center border border-teal-100 bg-white/80 px-3 text-sm font-medium text-teal-800 shadow-[3px_3px_0_0_rgba(20,184,166,0.08)] transition-colors hover:border-teal-200 hover:bg-teal-50 md:h-10 md:max-w-none"
|
type="button"
|
||||||
|
class="inline-flex h-8 max-w-[8.5rem] items-center gap-2 border border-teal-100 bg-white/80 px-3 text-sm font-medium text-teal-800 shadow-[3px_3px_0_0_rgba(20,184,166,0.08)] transition-colors hover:border-teal-200 hover:bg-teal-50 md:h-10 md:max-w-none"
|
||||||
:class="route.name === 'profile' || route.name === 'profile-settings' ? 'bg-teal-50 ring-1 ring-teal-200' : ''"
|
:class="route.name === 'profile' || route.name === 'profile-settings' ? 'bg-teal-50 ring-1 ring-teal-200' : ''"
|
||||||
|
aria-haspopup="menu"
|
||||||
|
:aria-expanded="accountCardOpen"
|
||||||
>
|
>
|
||||||
<span class="truncate">@{{ authStore.profile?.username }}</span>
|
<NIcon size="16" class="shrink-0">
|
||||||
</span>
|
<User />
|
||||||
</RouterLink>
|
</NIcon>
|
||||||
<NButton
|
<span class="truncate">@{{ displayUsername }}</span>
|
||||||
quaternary
|
</button>
|
||||||
size="small"
|
|
||||||
class="md:!h-10 md:!px-4 md:!text-sm"
|
<div
|
||||||
@click="authStore.logout()"
|
v-if="accountCardOpen"
|
||||||
>
|
class="absolute right-0 top-full z-50 w-72 pt-3"
|
||||||
登出
|
role="menu"
|
||||||
</NButton>
|
>
|
||||||
|
<div class="border border-slate-200 bg-white shadow-[8px_8px_0_0_rgba(15,23,42,0.08)]">
|
||||||
|
<div class="border-b border-slate-200 bg-[linear-gradient(180deg,#f0fdfa_0%,#ffffff_100%)] px-4 py-4">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="flex h-11 w-11 shrink-0 items-center justify-center border border-teal-200 bg-[linear-gradient(135deg,#ecfeff_0%,#ccfbf1_100%)] text-lg font-bold text-teal-800">
|
||||||
|
{{ displayUsername.slice(0, 1).toUpperCase() }}
|
||||||
|
</div>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<p class="truncate text-sm font-semibold text-slate-950">@{{ displayUsername }}</p>
|
||||||
|
<p class="mt-1 truncate text-xs text-slate-500">{{ userEmail }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3 flex items-center gap-2 text-xs text-slate-500">
|
||||||
|
<span class="border border-teal-100 bg-teal-50 px-2 py-1 text-teal-700">
|
||||||
|
{{ authStore.isAdmin ? '管理员' : '观测者' }}
|
||||||
|
</span>
|
||||||
|
<span>OpenCloud 账号</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="py-2">
|
||||||
|
<RouterLink
|
||||||
|
to="/profile"
|
||||||
|
class="flex items-center gap-3 px-4 py-2.5 text-sm font-medium text-slate-700 transition-colors hover:bg-teal-50 hover:text-teal-800"
|
||||||
|
role="menuitem"
|
||||||
|
>
|
||||||
|
<NIcon size="18"><User /></NIcon>
|
||||||
|
<span>个人中心</span>
|
||||||
|
</RouterLink>
|
||||||
|
<RouterLink
|
||||||
|
to="/profile/settings"
|
||||||
|
class="flex items-center gap-3 px-4 py-2.5 text-sm font-medium text-slate-700 transition-colors hover:bg-teal-50 hover:text-teal-800"
|
||||||
|
role="menuitem"
|
||||||
|
>
|
||||||
|
<NIcon size="18"><Settings /></NIcon>
|
||||||
|
<span>账号设置</span>
|
||||||
|
</RouterLink>
|
||||||
|
<RouterLink
|
||||||
|
v-if="authStore.isAdmin"
|
||||||
|
to="/admin"
|
||||||
|
class="flex items-center gap-3 px-4 py-2.5 text-sm font-medium text-slate-700 transition-colors hover:bg-amber-50 hover:text-amber-700"
|
||||||
|
role="menuitem"
|
||||||
|
>
|
||||||
|
<NIcon size="18"><Shield /></NIcon>
|
||||||
|
<span>管理后台</span>
|
||||||
|
</RouterLink>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="border-t border-slate-200 p-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex w-full items-center gap-3 px-2 py-2.5 text-left text-sm font-medium text-rose-600 transition-colors hover:bg-rose-50 hover:text-rose-700"
|
||||||
|
role="menuitem"
|
||||||
|
@click="handleLogout"
|
||||||
|
>
|
||||||
|
<NIcon size="18"><Logout /></NIcon>
|
||||||
|
<span>登出</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
@@ -181,13 +286,23 @@ onMounted(() => {
|
|||||||
to="/login"
|
to="/login"
|
||||||
class="no-underline"
|
class="no-underline"
|
||||||
>
|
>
|
||||||
<NButton quaternary size="small" class="md:!h-10 md:!px-4 md:!text-sm">登录</NButton>
|
<span
|
||||||
|
class="inline-flex h-8 items-center border border-slate-200 bg-white/80 px-3 text-sm font-medium text-slate-700 shadow-[3px_3px_0_0_rgba(15,23,42,0.06)] transition-colors hover:border-teal-200 hover:bg-teal-50 hover:text-teal-800 md:h-10 md:px-4"
|
||||||
|
:class="route.name === 'login' ? 'bg-teal-50 ring-1 ring-teal-200 text-teal-800' : ''"
|
||||||
|
>
|
||||||
|
登录
|
||||||
|
</span>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
<RouterLink
|
<RouterLink
|
||||||
to="/register"
|
to="/register"
|
||||||
class="no-underline"
|
class="no-underline"
|
||||||
>
|
>
|
||||||
<NButton type="primary" strong size="small" class="md:!h-10 md:!px-4 md:!text-sm">注册</NButton>
|
<span
|
||||||
|
class="inline-flex h-8 items-center border border-sky-200 bg-sky-100 px-3 text-sm font-medium text-sky-800 shadow-[4px_4px_0_0_rgba(14,165,233,0.14)] transition-colors hover:bg-sky-50 hover:text-sky-900 md:h-10 md:px-4"
|
||||||
|
:class="route.name === 'register' ? 'ring-1 ring-sky-300' : ''"
|
||||||
|
>
|
||||||
|
注册
|
||||||
|
</span>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
</template>
|
</template>
|
||||||
</NSpace>
|
</NSpace>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||||
import { NAlert, NButton, NDropdown, NEmpty, NIcon, NSkeleton, NTag, useMessage } from 'naive-ui'
|
import { NAlert, NButton, NDropdown, NEmpty, NIcon, NSkeleton, NTag, useMessage } from 'naive-ui'
|
||||||
import { Clock, Location, Settings, User } from '@vicons/tabler'
|
import { Clock, Location, Search, Settings, User, X } from '@vicons/tabler'
|
||||||
import CloudEditModal, { type CloudEditFormValue } from '@/components/cloud/CloudEditModal.vue'
|
import CloudEditModal, { type CloudEditFormValue } from '@/components/cloud/CloudEditModal.vue'
|
||||||
import ImageDetailModal from '@/components/cloud/ImageDetailModal.vue'
|
import ImageDetailModal from '@/components/cloud/ImageDetailModal.vue'
|
||||||
import MiniLocationMap from '@/components/cloud/MiniLocationMap.vue'
|
import MiniLocationMap from '@/components/cloud/MiniLocationMap.vue'
|
||||||
@@ -44,6 +44,7 @@ const loadingMore = ref(false)
|
|||||||
const loadError = ref('')
|
const loadError = ref('')
|
||||||
const galleryItems = ref<GalleryCloud[]>([])
|
const galleryItems = ref<GalleryCloud[]>([])
|
||||||
const selectedTypeId = ref<number | 'all'>('all')
|
const selectedTypeId = ref<number | 'all'>('all')
|
||||||
|
const searchQuery = ref('')
|
||||||
const selectedCloud = ref<GalleryCloud | null>(null)
|
const selectedCloud = ref<GalleryCloud | null>(null)
|
||||||
const sentinel = ref<HTMLDivElement | null>(null)
|
const sentinel = ref<HTMLDivElement | null>(null)
|
||||||
const totalLoaded = ref(0)
|
const totalLoaded = ref(0)
|
||||||
@@ -54,6 +55,7 @@ const editSaving = ref(false)
|
|||||||
const editInitialValue = ref<CloudEditFormValue | null>(null)
|
const editInitialValue = ref<CloudEditFormValue | null>(null)
|
||||||
|
|
||||||
let observer: IntersectionObserver | null = null
|
let observer: IntersectionObserver | null = null
|
||||||
|
let searchTimer: number | null = null
|
||||||
|
|
||||||
const rarityMeta = {
|
const rarityMeta = {
|
||||||
common: { label: '常见', chip: 'bg-sky-100 text-sky-700 border-sky-200' },
|
common: { label: '常见', chip: 'bg-sky-100 text-sky-700 border-sky-200' },
|
||||||
@@ -84,6 +86,37 @@ function formatCoordinate(value: number | null) {
|
|||||||
return value === null ? '未记录' : value.toFixed(2)
|
return value === null ? '未记录' : value.toFixed(2)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const normalizedSearch = computed(() => searchQuery.value.trim())
|
||||||
|
const isUserSearch = computed(() => normalizedSearch.value.startsWith('@'))
|
||||||
|
|
||||||
|
function sanitizeSearchTerm(term: string) {
|
||||||
|
return term.replace(/[(),*%]/g, ' ').replace(/\s+/g, ' ').trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMatchedCloudTypeIds(term: string) {
|
||||||
|
const lowerTerm = term.toLocaleLowerCase('zh-CN')
|
||||||
|
return cloudsStore.cloudTypes
|
||||||
|
.filter(type => {
|
||||||
|
return type.name.toLocaleLowerCase('zh-CN').includes(lowerTerm) ||
|
||||||
|
type.name_en.toLocaleLowerCase('zh-CN').includes(lowerTerm)
|
||||||
|
})
|
||||||
|
.map(type => type.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchUserIdsBySearch(term: string) {
|
||||||
|
const sanitized = sanitizeSearchTerm(term)
|
||||||
|
if (!sanitized) return []
|
||||||
|
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('profiles')
|
||||||
|
.select('id')
|
||||||
|
.ilike('username', `%${sanitized}%`)
|
||||||
|
.limit(100)
|
||||||
|
|
||||||
|
if (error) throw error
|
||||||
|
return ((data || []) as Array<{ id: string }>).map(profile => profile.id)
|
||||||
|
}
|
||||||
|
|
||||||
function toGalleryCloud(row: Record<string, unknown>) {
|
function toGalleryCloud(row: Record<string, unknown>) {
|
||||||
const cloudTypes = Array.isArray(row.cloud_types) ? row.cloud_types : row.cloud_types ? [row.cloud_types] : []
|
const cloudTypes = Array.isArray(row.cloud_types) ? row.cloud_types : row.cloud_types ? [row.cloud_types] : []
|
||||||
const profiles = Array.isArray(row.profiles) ? row.profiles : row.profiles ? [row.profiles] : []
|
const profiles = Array.isArray(row.profiles) ? row.profiles : row.profiles ? [row.profiles] : []
|
||||||
@@ -112,6 +145,16 @@ function toGalleryCloud(row: Record<string, unknown>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function fetchPage(offset: number) {
|
async function fetchPage(offset: number) {
|
||||||
|
const search = normalizedSearch.value
|
||||||
|
const usernameTerm = isUserSearch.value ? sanitizeSearchTerm(search.slice(1)) : ''
|
||||||
|
const cloudTypeTerm = !isUserSearch.value ? sanitizeSearchTerm(search) : ''
|
||||||
|
const userIds = usernameTerm ? await fetchUserIdsBySearch(usernameTerm) : []
|
||||||
|
const matchedCloudTypeIds = cloudTypeTerm ? getMatchedCloudTypeIds(cloudTypeTerm) : []
|
||||||
|
|
||||||
|
if (isUserSearch.value && !usernameTerm) return []
|
||||||
|
if (search && !isUserSearch.value && !cloudTypeTerm) return []
|
||||||
|
if (usernameTerm && userIds.length === 0) return []
|
||||||
|
|
||||||
let query = supabase
|
let query = supabase
|
||||||
.from('clouds')
|
.from('clouds')
|
||||||
.select('id,user_id,cloud_type_id,image_url,thumbnail_url,location_name,description,latitude,longitude,captured_at,created_at,status,is_hidden,custom_cloud_type,cloud_types(name,rarity),profiles(username)')
|
.select('id,user_id,cloud_type_id,image_url,thumbnail_url,location_name,description,latitude,longitude,captured_at,created_at,status,is_hidden,custom_cloud_type,cloud_types(name,rarity),profiles(username)')
|
||||||
@@ -124,6 +167,16 @@ async function fetchPage(offset: number) {
|
|||||||
query = query.eq('cloud_type_id', selectedTypeId.value)
|
query = query.eq('cloud_type_id', selectedTypeId.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (usernameTerm) {
|
||||||
|
query = query.in('user_id', userIds)
|
||||||
|
} else if (cloudTypeTerm) {
|
||||||
|
if (matchedCloudTypeIds.length) {
|
||||||
|
query = query.or(`cloud_type_id.in.(${matchedCloudTypeIds.join(',')}),custom_cloud_type.ilike.*${cloudTypeTerm}*`)
|
||||||
|
} else {
|
||||||
|
query = query.ilike('custom_cloud_type', `%${cloudTypeTerm}%`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const { data, error } = await query
|
const { data, error } = await query
|
||||||
if (error) throw error
|
if (error) throw error
|
||||||
|
|
||||||
@@ -348,6 +401,10 @@ function handleCloudAction(key: string | number) {
|
|||||||
if (key === 'toggle-privacy') hideSelectedCloud()
|
if (key === 'toggle-privacy') hideSelectedCloud()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clearSearch() {
|
||||||
|
searchQuery.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
const filterTabs = computed(() => [
|
const filterTabs = computed(() => [
|
||||||
{ id: 'all' as const, label: '全部' },
|
{ id: 'all' as const, label: '全部' },
|
||||||
...cloudsStore.cloudTypes.map(type => ({ id: type.id, label: type.name })),
|
...cloudsStore.cloudTypes.map(type => ({ id: type.id, label: type.name })),
|
||||||
@@ -363,11 +420,25 @@ watch(selectedTypeId, async () => {
|
|||||||
await loadInitial()
|
await loadInitial()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
watch(searchQuery, () => {
|
||||||
|
if (searchTimer !== null) {
|
||||||
|
window.clearTimeout(searchTimer)
|
||||||
|
}
|
||||||
|
searchTimer = window.setTimeout(async () => {
|
||||||
|
selectedCloud.value = null
|
||||||
|
await loadInitial()
|
||||||
|
searchTimer = null
|
||||||
|
}, 250)
|
||||||
|
})
|
||||||
|
|
||||||
watch(sentinel, () => {
|
watch(sentinel, () => {
|
||||||
if (!loading.value) setupObserver()
|
if (!loading.value) setupObserver()
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
|
if (searchTimer !== null) {
|
||||||
|
window.clearTimeout(searchTimer)
|
||||||
|
}
|
||||||
observer?.disconnect()
|
observer?.disconnect()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
@@ -386,6 +457,40 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
<div class="max-w-7xl mx-auto px-4 py-8">
|
<div class="max-w-7xl mx-auto px-4 py-8">
|
||||||
<section>
|
<section>
|
||||||
|
<div class="mb-4 border border-slate-200 bg-white p-3 shadow-[6px_6px_0_0_rgba(15,23,42,0.05)]">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<NIcon size="20" class="shrink-0 text-slate-400">
|
||||||
|
<Search />
|
||||||
|
</NIcon>
|
||||||
|
<input
|
||||||
|
v-model="searchQuery"
|
||||||
|
type="text"
|
||||||
|
class="min-w-0 flex-1 bg-transparent text-sm text-slate-800 outline-none placeholder:text-slate-400"
|
||||||
|
placeholder="搜索云型名称,或输入 @用户名 查看某个用户的图片"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
v-if="searchQuery"
|
||||||
|
type="button"
|
||||||
|
class="flex h-8 w-8 shrink-0 items-center justify-center text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-700"
|
||||||
|
title="清空搜索"
|
||||||
|
aria-label="清空搜索"
|
||||||
|
@click="clearSearch"
|
||||||
|
>
|
||||||
|
<NIcon size="18">
|
||||||
|
<X />
|
||||||
|
</NIcon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p v-if="normalizedSearch" class="mt-2 text-xs text-slate-500">
|
||||||
|
<template v-if="isUserSearch">
|
||||||
|
正在按上传者搜索:{{ normalizedSearch.slice(1) || '请输入用户名' }}
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
正在匹配云型名称和自定义云型:{{ normalizedSearch }}
|
||||||
|
</template>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex gap-3 overflow-x-auto pb-2">
|
<div class="flex gap-3 overflow-x-auto pb-2">
|
||||||
<NButton
|
<NButton
|
||||||
v-for="tab in filterTabs"
|
v-for="tab in filterTabs"
|
||||||
|
|||||||
Reference in New Issue
Block a user