feat(ui): 重构 SearchBar、集成 useScrollTitle 标题滚动显示、修复专辑搜索跳转

- 重新设计 SearchBar:左侧 Tab(播放列表/MV/排行榜)+ 滑动指示器 + 搜索框自动展开收缩
- 新增 navTitle store 和 useScrollTitle hook,支持页面滚动后在 SearchBar 显示标题
- 集成 useScrollTitle 到 MusicListPage、歌手详情、关注/粉丝列表、搜索结果页
- 修复搜索结果页专辑点击跳转失败(缺失 type 字段)
- 新增 5 种语言 searchBar tab i18n 键值
This commit is contained in:
alger
2026-03-15 14:11:59 +08:00
parent 067868f786
commit 57a441312f
15 changed files with 1103 additions and 498 deletions

View File

@@ -173,7 +173,11 @@ export default {
zoom: 'Zoom',
zoom100: 'Zoom 100%',
resetZoom: 'Reset Zoom',
zoomDefault: 'Default Zoom'
zoomDefault: 'Default Zoom',
tabPlaylist: 'Playlist',
tabMv: 'MV',
tabCharts: 'Charts',
cancelSearch: 'Cancel'
},
titleBar: {
closeTitle: 'Choose how to close',
@@ -199,6 +203,7 @@ export default {
addToPlaylistSuccess: 'Add to Playlist Success',
operationFailed: 'Operation Failed',
songsAlreadyInPlaylist: 'Songs already in playlist',
locateCurrent: 'Locate current song',
historyRecommend: 'Daily History',
fetchDatesFailed: 'Failed to fetch dates',
fetchSongsFailed: 'Failed to fetch songs',

View File

@@ -173,7 +173,11 @@ export default {
zoom: 'ページズーム',
zoom100: '標準ズーム100%',
resetZoom: 'クリックしてズームをリセット',
zoomDefault: '標準ズーム'
zoomDefault: '標準ズーム',
tabPlaylist: 'プレイリスト',
tabMv: 'MV',
tabCharts: 'チャート',
cancelSearch: 'キャンセル'
},
titleBar: {
closeTitle: '閉じる方法を選択してください',
@@ -199,6 +203,7 @@ export default {
addToPlaylist: 'プレイリストに追加',
addToPlaylistSuccess: 'プレイリストに追加しました',
songsAlreadyInPlaylist: '楽曲は既にプレイリストに存在します',
locateCurrent: '再生中の曲を表示',
historyRecommend: '履歴の日次推薦',
fetchDatesFailed: '日付リストの取得に失敗しました',
fetchSongsFailed: '楽曲リストの取得に失敗しました',

View File

@@ -172,7 +172,11 @@ export default {
zoom: '페이지 확대/축소',
zoom100: '표준 확대/축소 100%',
resetZoom: '클릭하여 확대/축소 재설정',
zoomDefault: '표준 확대/축소'
zoomDefault: '표준 확대/축소',
tabPlaylist: '플레이리스트',
tabMv: 'MV',
tabCharts: '차트',
cancelSearch: '취소'
},
titleBar: {
closeTitle: '닫기 방법을 선택해주세요',
@@ -198,6 +202,7 @@ export default {
addToPlaylist: '재생 목록에 추가',
addToPlaylistSuccess: '재생 목록에 추가 성공',
songsAlreadyInPlaylist: '곡이 이미 재생 목록에 있습니다',
locateCurrent: '현재 재생 곡 찾기',
historyRecommend: '일일 기록 권장',
fetchDatesFailed: '날짜를 가져오지 못했습니다',
fetchSongsFailed: '곡을 가져오지 못했습니다',

View File

@@ -166,7 +166,11 @@ export default {
zoom: '页面缩放',
zoom100: '标准缩放100%',
resetZoom: '点击重置缩放',
zoomDefault: '标准缩放'
zoomDefault: '标准缩放',
tabPlaylist: '播放列表',
tabMv: 'MV',
tabCharts: '排行榜',
cancelSearch: '取消'
},
titleBar: {
closeTitle: '请选择关闭方式',
@@ -192,6 +196,7 @@ export default {
addToPlaylist: '添加到播放列表',
addToPlaylistSuccess: '添加到播放列表成功',
songsAlreadyInPlaylist: '歌曲已存在于播放列表中',
locateCurrent: '定位当前播放',
historyRecommend: '历史日推',
fetchDatesFailed: '获取日期列表失败',
fetchSongsFailed: '获取歌曲列表失败',

View File

@@ -166,7 +166,11 @@ export default {
zoom: '頁面縮放',
zoom100: '標準縮放100%',
resetZoom: '點擊重設縮放',
zoomDefault: '標準縮放'
zoomDefault: '標準縮放',
tabPlaylist: '播放清單',
tabMv: 'MV',
tabCharts: '排行榜',
cancelSearch: '取消'
},
titleBar: {
closeTitle: '請選擇關閉方式',
@@ -192,6 +196,7 @@ export default {
addToPlaylist: '新增至播放清單',
addToPlaylistSuccess: '新增至播放清單成功',
songsAlreadyInPlaylist: '歌曲已存在於播放清單中',
locateCurrent: '定位當前播放',
historyRecommend: '歷史日推',
fetchDatesFailed: '獲取日期列表失敗',
fetchSongsFailed: '獲取歌曲列表失敗',

View File

@@ -103,7 +103,10 @@ const handleClick = async () => {
id: props.item.id,
type: 'album',
name: props.item.name,
listInfo: { picUrl: props.item.picUrl },
listInfo: {
...props.item,
coverImgUrl: props.item.picUrl
},
canRemove: false
});
} else if (props.item.type === 'playlist') {

View File

@@ -0,0 +1,58 @@
import { isRef, onMounted, onUnmounted, Ref, watch } from 'vue';
import { useNavTitleStore } from '@/store/modules/navTitle';
/**
* 页面标题滚动监听 hook
*
* 当 titleEl 元素滚出视口时,在 SearchBar 中显示标题。
* 滚回视口后自动隐藏 SearchBar 中的标题。
*
* @param title 标题文本(字符串或 Ref<string>
* @param titleEl 需要被监听的标题 DOM 元素(通常是页面 h1/h2
*
* @example
* // 在页面组件中:
* const titleRef = ref<HTMLElement | null>(null);
* useScrollTitle('歌单名称', titleRef);
* // 或响应式标题:
* useScrollTitle(computed(() => playlist.value?.name ?? ''), titleRef);
*/
export function useScrollTitle(title: string | Ref<string>, titleEl: Ref<HTMLElement | null>) {
const store = useNavTitleStore();
let observer: IntersectionObserver | null = null;
const setupObserver = (el: HTMLElement) => {
observer?.disconnect();
observer = new IntersectionObserver(([entry]) => store.setVisible(!entry.isIntersecting), {
threshold: 0,
rootMargin: '-56px 0px 0px 0px'
});
observer.observe(el);
};
onMounted(() => {
// 设置初始标题
store.setTitle(isRef(title) ? title.value : title);
// 等待 DOM 就绪后启动 observer
if (titleEl.value) {
setupObserver(titleEl.value);
}
});
// 响应式标题:当 Ref<string> 变化时同步更新 store
if (isRef(title)) {
watch(title, (v) => store.setTitle(v));
}
// titleEl 延迟挂载时补充 observer
watch(titleEl, (el) => {
if (el) setupObserver(el);
});
onUnmounted(() => {
observer?.disconnect();
store.clear();
});
}

File diff suppressed because it is too large Load Diff

View File

@@ -21,6 +21,7 @@ export * from './modules/localMusic';
export * from './modules/lyric';
export * from './modules/menu';
export * from './modules/music';
export * from './modules/navTitle';
export * from './modules/player';
export * from './modules/playerCore';
export * from './modules/playHistory';

View File

@@ -0,0 +1,27 @@
import { defineStore } from 'pinia';
import { ref } from 'vue';
/**
* 导航栏标题 store
* 用于页面滚动后在 SearchBar 中显示当前页面标题
* 无持久化,页面卸载时自动清除
*/
export const useNavTitleStore = defineStore('navTitle', () => {
const title = ref('');
const isVisible = ref(false);
const setTitle = (t: string) => {
title.value = t;
};
const setVisible = (v: boolean) => {
isVisible.value = v;
};
const clear = () => {
title.value = '';
isVisible.value = false;
};
return { title, isVisible, setTitle, setVisible, clear };
});

View File

@@ -100,6 +100,7 @@
</span>
</div>
<h1
ref="titleElRef"
class="artist-name text-3xl md:text-4xl lg:text-5xl font-bold text-neutral-900 dark:text-white tracking-tight"
>
{{ artistInfo.name }}
@@ -434,6 +435,7 @@ import { getMusicDetail } from '@/api/music';
import { navigateToMusicList } from '@/components/common/MusicListNavigator';
import PlayBottom from '@/components/common/PlayBottom.vue';
import SongItem from '@/components/common/SongItem.vue';
import { useScrollTitle } from '@/hooks/useScrollTitle';
import router from '@/router';
import { usePlayerStore } from '@/store';
import { IArtist } from '@/types/artist';
@@ -465,6 +467,10 @@ const artistInfo = ref<IArtist>();
const songs = ref<any[]>([]);
const albums = ref<any[]>([]);
const titleElRef = ref<HTMLElement | null>(null);
const artistTitle = computed(() => artistInfo.value?.name ?? '');
useScrollTitle(artistTitle, titleElRef);
// 加载状态
const loading = ref(false);
const songLoading = ref(false);

View File

@@ -1,6 +1,6 @@
<template>
<div class="music-list-page h-full w-full bg-white dark:bg-black transition-colors duration-500">
<n-scrollbar class="h-full" @scroll="handleScroll">
<n-scrollbar ref="scrollbarRef" class="h-full" @scroll="handleScroll">
<div class="music-list-content pb-32">
<!-- Hero Section Action Bar -->
<n-spin :show="loading">
@@ -63,6 +63,7 @@
</span>
</div>
<h1
ref="titleElRef"
class="playlist-name text-3xl md:text-4xl lg:text-5xl font-bold text-neutral-900 dark:text-white tracking-tight mb-4"
>
{{ name }}
@@ -212,6 +213,16 @@
</n-input>
</div>
<!-- Locate Current Song -->
<button
v-if="currentPlayingIndex >= 0"
class="action-btn-icon w-10 h-10 rounded-full flex items-center justify-center bg-neutral-100 dark:bg-neutral-900 text-neutral-600 dark:text-neutral-400 hover:bg-neutral-200 dark:hover:bg-neutral-800 transition-all"
:title="t('comp.musicList.locateCurrent', '定位当前播放')"
@click="scrollToCurrentSong"
>
<i class="ri-focus-3-line text-lg" />
</button>
<!-- Layout Toggle -->
<button
v-if="!isMobile"
@@ -226,7 +237,6 @@
<!-- List Content -->
<section class="song-list-section page-padding-x mt-6">
<n-spin :show="loadingList">
<div
v-if="filteredSongs.length === 0 && searchKeyword"
class="empty-state py-20 text-center text-neutral-400"
@@ -239,8 +249,13 @@
<div
v-for="(item, index) in filteredSongs"
:key="item.id"
class="mb-2 animate-item"
:style="{ animationDelay: calculateAnimationDelay(index % 20, 0.03) }"
class="mb-2"
:class="{ 'animate-item': index < initialAnimateCount }"
:style="
index < initialAnimateCount
? { animationDelay: calculateAnimationDelay(index, 0.03) }
: undefined
"
>
<song-item
:index="index"
@@ -254,8 +269,27 @@
@select="(id, selected) => handleSelect(id, selected)"
/>
</div>
<!-- 未渲染项占位保持滚动条高度稳定 -->
<div v-if="placeholderHeight > 0" :style="{ height: placeholderHeight + 'px' }" />
<!-- 底部加载指示器 -->
<div v-if="loadingList" class="flex items-center justify-center py-6 gap-2">
<n-spin :size="18" />
<span class="text-sm text-neutral-400">{{ t('common.loading') }}</span>
</div>
<div
v-else-if="
!hasMore &&
renderLimit >= allFilteredSongs.length &&
filteredSongs.length > 0 &&
!searchKeyword
"
class="py-6 text-center text-sm text-neutral-300 dark:text-neutral-600"
>
— {{ t('common.noMore') }} —
</div>
</div>
</n-spin>
</section>
</div>
</n-scrollbar>
@@ -266,7 +300,7 @@
<script setup lang="ts">
import { useMessage } from 'naive-ui';
import PinyinMatch from 'pinyin-match';
import { computed, onMounted, ref, watch } from 'vue';
import { computed, nextTick, onMounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
@@ -280,6 +314,7 @@ import {
import PlayBottom from '@/components/common/PlayBottom.vue';
import SongItem from '@/components/common/SongItem.vue';
import { useDownload } from '@/hooks/useDownload';
import { useScrollTitle } from '@/hooks/useScrollTitle';
import { useMusicStore, usePlayerStore, useRecommendStore, useUserStore } from '@/store';
import { usePlayHistoryStore } from '@/store/modules/playHistory';
import { SongResult } from '@/types/music';
@@ -370,6 +405,9 @@ const name = computed(() => {
return musicStore.currentMusicListName || '';
});
const titleElRef = ref<HTMLElement | null>(null);
useScrollTitle(name, titleElRef);
const songList = computed(() => {
if (isDailyRecommend.value) return recommendStore.dailyRecommendSongs;
return musicStore.currentMusicList || [];
@@ -388,7 +426,9 @@ const canRemove = computed(() => {
const canCollect = ref(false);
const isCollected = ref(false);
const pageSize = 40;
const initialAnimateCount = 20; // 仅前 20 项有入场动画
const displayedSongs = ref<SongResult[]>([]);
const renderLimit = ref(pageSize); // DOM 渲染上限,数据全部在内存
const loadingList = ref(false);
const loadedIds = ref(new Set<number>());
const isPlaylistLoading = ref(false);
@@ -417,14 +457,17 @@ const getCoverImgUrl = computed(() => {
return song?.picUrl || song?.al?.picUrl || song?.album?.picUrl || '';
});
const filteredSongs = computed(() => {
// 全量歌曲列表(用于"播放全部"等操作)
const allFilteredSongs = computed(() => {
const sourceList = isDailyRecommend.value ? songList.value : displayedSongs.value;
const dislikeFilteredList = sourceList.filter((s) => !playerStore.dislikeList.includes(s.id));
if (!searchKeyword.value) return dislikeFilteredList;
return sourceList.filter((s) => !playerStore.dislikeList.includes(s.id));
});
// 实际渲染到 DOM 的歌曲(搜索时显示全部匹配,非搜索时按 renderLimit 分页渲染)
const filteredSongs = computed(() => {
if (searchKeyword.value) {
const keyword = searchKeyword.value.toLowerCase().trim();
return dislikeFilteredList.filter((song) => {
return allFilteredSongs.value.filter((song) => {
const songName = song.name?.toLowerCase() || '';
const albumName = song.al?.name?.toLowerCase() || '';
const artists = song.ar || song.artists || [];
@@ -435,6 +478,16 @@ const filteredSongs = computed(() => {
PinyinMatch.match(songName, keyword)
);
});
}
return allFilteredSongs.value.slice(0, renderLimit.value);
});
// 未渲染项的占位高度,让滚动条从一开始就反映真实总高度
const estimatedItemHeight = computed(() => (isCompactLayout.value ? 50 : 70));
const placeholderHeight = computed(() => {
if (searchKeyword.value) return 0;
const unrenderedCount = allFilteredSongs.value.length - filteredSongs.value.length;
return Math.max(0, unrenderedCount) * estimatedItemHeight.value;
});
const resetListState = () => {
@@ -520,7 +573,7 @@ const handlePlayAll = () => {
? filteredSongs.value
: isFullPlaylistLoaded.value
? completePlaylist.value
: displayedSongs.value;
: allFilteredSongs.value;
playerStore.setPlayList(list.map(formatSong));
playerStore.setPlay(formatSong(list[0]));
if (!isFullPlaylistLoaded.value) loadFullPlaylist();
@@ -553,17 +606,32 @@ const handleRemoveSong = async (songId: number) => {
}
};
// 根据滚动位置计算需要渲染多少项,快速滚动也不会出现空白
const handleScroll = (e: Event) => {
const target = e.target as HTMLElement;
const { scrollTop, scrollHeight, clientHeight } = target;
if (searchKeyword.value) return;
// 懒加载:滚动到底部时加载更多
if (
scrollHeight - scrollTop - clientHeight < 200 &&
!loadingList.value &&
hasMore.value &&
!searchKeyword.value
) {
const target = e.target as HTMLElement;
const { scrollTop, clientHeight } = target;
// 列表区域在滚动内容中的起始偏移hero + action bar + margin
const listSection = document.querySelector('.song-list-section') as HTMLElement;
const listStart = listSection?.offsetTop || 0;
// 当前可见区域底部在列表中的位置
const visibleBottom = scrollTop + clientHeight - listStart;
if (visibleBottom <= 0) return;
// 计算需要渲染到第几项(多渲染一屏作为缓冲)
const bufferHeight = clientHeight;
const neededIndex = Math.ceil((visibleBottom + bufferHeight) / estimatedItemHeight.value);
const allCount = allFilteredSongs.value.length;
if (neededIndex > renderLimit.value) {
renderLimit.value = Math.min(neededIndex, allCount);
}
// 内存数据全部渲染完但还有更多数据需要从 API 加载
if (renderLimit.value >= allCount && !loadingList.value && hasMore.value) {
loadMoreSongs();
}
};
@@ -585,16 +653,10 @@ const loadMoreSongs = async () => {
.map((i) => i.id)
.filter((id) => !loadedIds.value.has(id));
if (ids.length > 0) await loadSongs(ids);
} else {
const newSongs = songList.value.slice(start, end);
newSongs.forEach((s) => {
if (!loadedIds.value.has(s.id)) {
loadedIds.value.add(s.id);
displayedSongs.value.push(s);
}
});
}
hasMore.value = displayedSongs.value.length < total.value;
// 新数据加载后扩展渲染窗口
renderLimit.value = displayedSongs.value.length;
} finally {
loadingList.value = false;
}
@@ -708,6 +770,60 @@ const handleAddToPlaylist = () => {
cancelSelect();
};
// 当前播放歌曲在列表中的索引
const currentPlayingIndex = computed(() => {
const currentId = playerStore.playMusic?.id;
if (!currentId) return -1;
return allFilteredSongs.value.findIndex((s) => s.id === currentId);
});
const scrollbarRef = ref<any>(null);
// 滚动到当前播放歌曲
const scrollToCurrentSong = async () => {
const index = currentPlayingIndex.value;
if (index < 0) return;
// 确保目标歌曲已渲染到 DOM
if (index >= renderLimit.value) {
renderLimit.value = index + 5;
await nextTick();
}
const container = document.querySelector('.song-list-container') as HTMLElement;
const target = container?.children[index] as HTMLElement;
if (!target || !scrollbarRef.value) return;
// 获取 n-scrollbar 内部的可滚动容器
const scrollEl = document.querySelector('.music-list-page .n-scrollbar-container') as HTMLElement;
if (!scrollEl) return;
// 用 getBoundingClientRect 精确测量目标位置
const scrollRect = scrollEl.getBoundingClientRect();
const targetRect = target.getBoundingClientRect();
const currentScrollTop = scrollEl.scrollTop;
// 目标在滚动内容中的绝对位置
const targetAbsoluteTop = currentScrollTop + targetRect.top - scrollRect.top;
// 粘性 action bar 占用的高度
const actionBarEl = document.querySelector('.action-bar') as HTMLElement;
const actionBarHeight = actionBarEl?.offsetHeight || 0;
// 可视区域高度(去掉 action bar
const visibleHeight = scrollRect.height - actionBarHeight;
// 滚动到目标居中(在可视区域中间)
const scrollTop = targetAbsoluteTop - actionBarHeight - visibleHeight / 2 + targetRect.height / 2;
scrollbarRef.value.scrollTo({ top: Math.max(0, scrollTop), behavior: 'smooth' });
// 短暂高亮效果
await nextTick();
target.classList.add('song-highlight');
setTimeout(() => target.classList.remove('song-highlight'), 2000);
};
const toggleLayout = () => {
isCompactLayout.value = !isCompactLayout.value;
localStorage.setItem('musicListLayout', isCompactLayout.value ? 'compact' : 'normal');
@@ -730,6 +846,7 @@ watch(
songList,
(newSongs) => {
resetListState();
renderLimit.value = pageSize; // 重置 DOM 渲染窗口
if (newSongs.length > 0) {
displayedSongs.value = [...newSongs];
newSongs.forEach((s) => loadedIds.value.add(s.id));
@@ -791,6 +908,21 @@ onMounted(checkCollectionStatus);
padding-bottom: 100px;
}
.song-highlight {
animation: highlightPulse 2s ease-out;
}
@keyframes highlightPulse {
0%,
30% {
background-color: rgba(var(--primary-color-rgb, 64, 128, 255), 0.15);
border-radius: 12px;
}
100% {
background-color: transparent;
}
}
.mobile {
.hero-section {
min-height: auto;

View File

@@ -9,6 +9,7 @@
<div class="flex flex-col gap-6">
<div>
<h1
ref="titleElRef"
class="text-3xl md:text-4xl font-bold tracking-tight text-neutral-900 dark:text-white mb-1"
>
{{ currentKeyword }}
@@ -245,6 +246,7 @@ import SearchItem from '@/components/common/SearchItem.vue';
import SongItem from '@/components/common/SongItem.vue';
import { SEARCH_TYPE, SEARCH_TYPES } from '@/const/bar-const';
import { useDownload } from '@/hooks/useDownload';
import { useScrollTitle } from '@/hooks/useScrollTitle';
import { usePlayerStore } from '@/store/modules/player';
import { useSearchStore } from '@/store/modules/search';
import type { SongResult } from '@/types/music';
@@ -283,6 +285,9 @@ const hasMore = ref(true);
const isLoadingMore = ref(false);
const currentKeyword = computed(() => (route.query.keyword as string) || '');
const titleElRef = ref<HTMLElement | null>(null);
useScrollTitle(currentKeyword, titleElRef);
const searchTypeOptions = computed(() => {
return SEARCH_TYPES.map((type) => ({
label: t(type.label),
@@ -406,6 +411,7 @@ const loadSearch = async (isLoadMore = false) => {
item.artists = item.ar;
});
albums.forEach((item: any) => {
item.type = '专辑';
item.desc = `${item.artist.name} ${item.company} ${dateFormat(item.publishTime)}`;
});

View File

@@ -23,6 +23,7 @@
<!-- Header Section -->
<section class="page-padding-x pt-6 md:pt-8 pb-4">
<h1
ref="titleElRef"
class="text-2xl md:text-3xl font-bold text-neutral-900 dark:text-white tracking-tight"
>
<template v-if="targetUserName">
@@ -134,6 +135,7 @@ import { useRoute, useRouter } from 'vue-router';
import { getUserFollowers } from '@/api/user';
import PlayBottom from '@/components/common/PlayBottom.vue';
import { useScrollTitle } from '@/hooks/useScrollTitle';
import { useUserStore } from '@/store/modules/user';
import type { IUserFollow } from '@/types/user';
import { getImgUrl } from '@/utils';
@@ -160,6 +162,14 @@ const targetUserName = ref<string>('');
const user = computed(() => userStore.user);
const titleElRef = ref<HTMLElement | null>(null);
const followersTitle = computed(() =>
targetUserName.value
? targetUserName.value + t('user.follower.userFollowersTitle')
: t('user.follower.myFollowersTitle')
);
useScrollTitle(followersTitle, titleElRef);
const checkTargetUser = () => {
const uid = route.query.uid;
const name = route.query.name;

View File

@@ -23,6 +23,7 @@
<!-- Header Section -->
<section class="page-padding-x pt-6 md:pt-8 pb-4">
<h1
ref="titleElRef"
class="text-2xl md:text-3xl font-bold text-neutral-900 dark:text-white tracking-tight"
>
<template v-if="targetUserName">
@@ -134,6 +135,7 @@ import { useRoute, useRouter } from 'vue-router';
import { getUserFollows } from '@/api/user';
import PlayBottom from '@/components/common/PlayBottom.vue';
import { useScrollTitle } from '@/hooks/useScrollTitle';
import { useUserStore } from '@/store/modules/user';
import type { IUserFollow } from '@/types/user';
import { getImgUrl } from '@/utils';
@@ -160,6 +162,14 @@ const targetUserName = ref<string>('');
const user = computed(() => userStore.user);
const titleElRef = ref<HTMLElement | null>(null);
const followsTitle = computed(() =>
targetUserName.value
? targetUserName.value + t('user.follow.userFollowsTitle')
: t('user.follow.myFollowsTitle')
);
useScrollTitle(followsTitle, titleElRef);
// 检查是否有指定用户ID
const checkTargetUser = () => {
const uid = route.query.uid;