Files
AlgerMusicPlayer/src/renderer/layout/components/SearchBar.vue
T
alger bc46024499 refactor(download): 重构下载系统,支持暂停/恢复/取消,修复歌词加载
- 新建 DownloadManager 类(主进程),每个任务独立 AbortController 控制
- 新建 Pinia useDownloadStore 作为渲染进程单一数据源
- 支持暂停/恢复/取消下载,支持断点续传(Range header)
- 批量下载全部完成后发送汇总系统通知,单首不重复通知
- 并发数可配置(1-5),队列持久化(重启后恢复)
- 修复下载列表不全、封面加载失败、通知重复等 bug
- 修复本地/下载歌曲歌词加载:优先从 ID3/FLAC 元数据提取,API 作为 fallback
- 删除 useDownloadStatus.ts,统一状态管理
- DownloadDrawer/DownloadPage 全面重写,移除 @apply 违规
- 新增 5 语言 i18n 键值(暂停/恢复/取消/排队中等)
2026-03-27 23:02:08 +08:00

987 lines
27 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="flex items-center gap-2 pb-4 pr-4 pl-1">
<!-- LEFT: Tabs搜索展开时隐藏 -->
<transition name="tab-slide">
<div
v-if="!isSearchExpanded && !showBackButton"
class="tabs-track flex-shrink-0"
ref="tabsTrackRef"
>
<div class="tab-slider-bg" :style="sliderStyle" />
<button
v-for="(tab, i) in tabs"
:key="tab.key"
:ref="(el) => setTabRef(el as HTMLElement, i)"
class="tab-btn"
:class="isTabActive(tab.path) ? 'tab-btn--on' : 'tab-btn--off'"
@click="router.push(tab.path)"
>
<i :class="tab.icon" />
<span>{{ tab.label }}</span>
</button>
</div>
<!-- 返回按钮 + 页面标题meta.back 页面-->
<div v-else-if="showBackButton" class="flex items-center gap-2 flex-shrink-0">
<button class="back-btn" @click="goBack">
<i class="ri-arrow-left-line" />
</button>
<transition name="nav-title">
<span v-if="navTitleStore.isVisible && !isSearchExpanded" class="nav-page-title">
{{ navTitleStore.title }}
</span>
</transition>
</div>
</transition>
<!-- SPACER搜索收起时撑开间距 -->
<div v-if="!isSearchExpanded" class="flex-1" />
<!-- 搜索输入框收起时固定宽展开时 flex-1 撑满-->
<div class="search-wrap" :class="isSearchExpanded ? 'search-wrap--open' : 'search-wrap--idle'">
<n-popover
trigger="manual"
placement="bottom-end"
:show="showSuggestions"
:show-arrow="false"
style="margin-top: 6px"
content-style="padding:0;border-radius:12px;overflow:hidden;box-shadow:0 6px 24px rgba(0,0,0,0.12);"
raw
>
<template #trigger>
<div class="search-inner" :class="{ 'search-inner--focus': inputFocused }">
<i class="iconfont icon-search search-icon-glyph" />
<input
ref="inputRef"
v-model="searchValue"
class="search-input"
:placeholder="hotSearchKeyword"
@input="handleInput(searchValue)"
@keydown="handleKeydown"
@focus="handleFocus"
@blur="handleBlur"
/>
<n-dropdown
v-if="searchTypeOptions.length && isSearchExpanded"
trigger="hover"
:options="searchTypeOptions"
@select="selectSearchType"
@mousedown.prevent
>
<div class="type-chip" @mousedown.prevent>
<span>{{
searchTypeOptions.find((i) => i.key === searchStore.searchType)?.label
}}</span>
<i class="iconfont icon-xiasanjiaoxing text-[10px]" />
</div>
</n-dropdown>
</div>
</template>
<div class="suggestions-box">
<n-scrollbar style="max-height: 260px">
<div v-if="suggestionsLoading" class="suggest-loading">
<n-spin size="small" />
</div>
<div
v-for="(s, i) in suggestions"
:key="i"
class="suggest-row"
:class="{ 'suggest-row--hi': i === highlightedIndex }"
@mousedown.prevent="selectSuggestion(s)"
@mouseenter="highlightedIndex = i"
>
<i class="ri-search-line suggest-icon" />
<span>{{ s }}</span>
</div>
</n-scrollbar>
</div>
</n-popover>
</div>
<!-- 下载按钮 -->
<button v-if="showDownloadButton" class="action-btn" @click="navigateToDownloads">
<n-badge :value="downloadingCount" :max="99" :show="downloadingCount > 0" :offset="[-2, 2]">
<i class="ri-download-cloud-2-line" />
</n-badge>
</button>
<!-- 心动模式按钮 -->
<n-tooltip v-if="showIntelligenceBtn" trigger="hover">
<template #trigger>
<button
class="action-btn"
:class="{ 'intelligence-active': isIntelligenceMode }"
@click="toggleIntelligenceMode"
>
<i class="ri-heart-pulse-line" />
</button>
</template>
{{
isIntelligenceMode
? t('comp.searchBar.exitIntelligence')
: t('comp.searchBar.intelligenceMode')
}}
</n-tooltip>
<!-- 用户 -->
<n-popover trigger="hover" placement="bottom-end" :show-arrow="false" raw>
<template #trigger>
<div class="user-btn">
<n-avatar
v-if="userStore.user"
circle
:size="26"
:src="getImgUrl(userStore.user.avatarUrl)"
class="cursor-pointer"
@click="selectItem('user')"
/>
<span v-else class="login-label" @click="toLogin">{{ t('comp.searchBar.login') }}</span>
</div>
</template>
<div class="user-menu">
<div v-if="userStore.user" class="user-menu-top" @click="selectItem('user')">
<n-avatar circle :size="30" :src="getImgUrl(userStore.user?.avatarUrl)" />
<span class="user-name">{{ userStore.user?.nickname }}</span>
</div>
<div v-if="userStore.user" class="menu-sep" />
<div class="menu-list">
<div v-if="!userStore.user" class="menu-row" @click="toLogin">
<i class="ri-login-box-line" /><span>{{ t('comp.searchBar.toLogin') }}</span>
</div>
<div v-if="userStore.user" class="menu-row" @click="selectItem('logout')">
<i class="ri-logout-box-r-line" /><span>{{ t('comp.searchBar.logout') }}</span>
</div>
<div class="menu-row" @click="selectItem('set')">
<i class="ri-settings-3-line" /><span>{{ t('comp.searchBar.set') }}</span>
</div>
<div v-if="isElectron" class="menu-row">
<i class="ri-zoom-in-line" /><span>{{ t('comp.searchBar.zoom') }}</span>
<div class="zoom-ctrl ml-auto">
<button class="zoom-btn" @click.stop="decreaseZoom">
<i class="ri-subtract-line" />
</button>
<n-tooltip trigger="hover">
<template #trigger>
<span
class="zoom-val"
:class="{ 'zoom-val--100': isZoom100() }"
@click.stop="resetZoom"
>
{{ Math.round(zoomFactor * 100) }}%
</span>
</template>
{{ isZoom100() ? t('comp.searchBar.zoom100') : t('comp.searchBar.resetZoom') }}
</n-tooltip>
<button class="zoom-btn" @click.stop="increaseZoom"><i class="ri-add-line" /></button>
</div>
</div>
<div class="menu-row">
<i :class="isDark ? 'ri-moon-line' : 'ri-sun-line'" />
<span>{{ t('comp.searchBar.theme') }}</span>
<n-switch v-model:value="isDark" class="ml-auto" size="small">
<template #checked><i class="ri-moon-line text-[10px]" /></template>
<template #unchecked><i class="ri-sun-line text-[10px]" /></template>
</n-switch>
</div>
<div class="menu-row" @click="restartApp">
<i class="ri-restart-line" /><span>{{ t('comp.searchBar.restart') }}</span>
</div>
<div class="menu-row" @click="selectItem('refresh')">
<i class="ri-refresh-line" /><span>{{ t('comp.searchBar.refresh') }}</span>
</div>
<div class="menu-sep" />
<div class="menu-row" @click="toGithubRelease">
<i class="ri-github-fill" /><span>{{ t('comp.searchBar.currentVersion') }}</span>
<span class="ver-chip ml-auto">{{ updateInfo.currentVersion }}</span>
<n-tag v-if="updateInfo.hasUpdate" type="success" size="small" class="ml-1">New</n-tag>
</div>
</div>
</div>
</n-popover>
<!-- GitHub -->
<coffee :alipay-q-r="alipay" :wechat-q-r="wechat">
<button class="action-btn" @click="toGithub">
<i class="ri-github-fill" />
</button>
</coffee>
</div>
</template>
<script lang="ts" setup>
import { useDebounceFn } from '@vueuse/core';
import { computed, onMounted, ref, watch, watchEffect } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute, useRouter } from 'vue-router';
import { getSearchKeyword } from '@/api/home';
import { getUserDetail } from '@/api/login';
import { getSearchSuggestions } from '@/api/search';
import alipay from '@/assets/alipay.png';
import wechat from '@/assets/wechat.png';
import Coffee from '@/components/Coffee.vue';
import { SEARCH_TYPES, USER_SET_OPTIONS } from '@/const/bar-const';
import { useZoom } from '@/hooks/useZoom';
import { useDownloadStore } from '@/store/modules/download';
import { useIntelligenceModeStore } from '@/store/modules/intelligenceMode';
import { useNavTitleStore } from '@/store/modules/navTitle';
import { useSearchStore } from '@/store/modules/search';
import { useSettingsStore } from '@/store/modules/settings';
import { useUserStore } from '@/store/modules/user';
import { getImgUrl, isElectron } from '@/utils';
import { checkUpdate, UpdateResult } from '@/utils/update';
import config from '../../../../package.json';
const router = useRouter();
const route = useRoute();
const navTitleStore = useNavTitleStore();
const searchStore = useSearchStore();
const settingsStore = useSettingsStore();
const userStore = useUserStore();
const userSetOptions = ref(USER_SET_OPTIONS);
const { t, locale } = useI18n();
const intelligenceModeStore = useIntelligenceModeStore();
const downloadStore = useDownloadStore();
const downloadingCount = computed(() => downloadStore.downloadingCount);
const navigateToDownloads = () => {
router.push('/downloads');
};
const showDownloadButton = computed(
() =>
isElectron && (settingsStore.setData?.alwaysShowDownloadButton || downloadingCount.value > 0)
);
const { zoomFactor, initZoomFactor, increaseZoom, decreaseZoom, resetZoom, isZoom100 } = useZoom();
// ── 心动模式 ─────────────────────────────────────────
const isIntelligenceMode = computed(() => intelligenceModeStore.isIntelligenceMode);
const showIntelligenceBtn = computed(() => userStore.user && userStore.loginType === 'cookie');
const toggleIntelligenceMode = async () => {
if (isIntelligenceMode.value) {
intelligenceModeStore.clearIntelligenceMode();
} else {
await intelligenceModeStore.playIntelligenceMode();
}
};
// ── Back button ───────────────────────────────────────
const showBackButton = computed(() => {
const meta = router.currentRoute.value.meta;
if (!settingsStore.isMobile && meta.isMobile === false) return false;
return meta.back === true;
});
const goBack = () => router.back();
// ── Tabs ──────────────────────────────────────────────
const tabs = computed(() => {
const items = [
{ key: 'home', label: t('comp.home'), path: '/', icon: 'ri-home-4-fill' },
{ key: 'playlist', label: t('comp.list'), path: '/list', icon: 'ri-play-list-2-fill' },
{ key: 'album', label: t('comp.newAlbum.title'), path: '/album', icon: 'ri-album-fill' },
{
key: 'charts',
label: t('comp.toplist'),
path: '/toplist',
icon: 'ri-bar-chart-grouped-fill'
},
{ key: 'mv', label: t('comp.mv'), path: '/mv', icon: 'ri-movie-2-fill' },
{
key: 'localMusic',
label: t('comp.localMusic'),
path: '/local-music',
icon: 'ri-folder-music-fill',
electronOnly: true
}
];
return items.filter((tab) => !tab.electronOnly || isElectron);
});
const isTabActive = (path: string) => route.path === path;
// Sliding pill
const tabsTrackRef = ref<HTMLElement | null>(null);
const tabElsRef = ref<HTMLElement[]>([]);
const setTabRef = (el: HTMLElement, i: number) => {
if (el) tabElsRef.value[i] = el;
};
const activeTabIndex = computed(() => tabs.value.findIndex((t) => isTabActive(t.path)));
const sliderStyle = computed(() => {
const el = tabElsRef.value[activeTabIndex.value];
if (!el) return { opacity: '0' };
return {
transform: `translateX(${el.offsetLeft}px)`,
width: `${el.offsetWidth}px`,
opacity: '1'
};
});
// ── Search expand / collapse ──────────────────────────
const isSearchExpanded = ref(false);
const inputFocused = ref(false);
const inputRef = ref<HTMLInputElement | null>(null);
const handleFocus = () => {
inputFocused.value = true;
isSearchExpanded.value = true;
if (searchValue.value && suggestions.value.length) showSuggestions.value = true;
};
const handleBlur = () => {
inputFocused.value = false;
setTimeout(() => {
showSuggestions.value = false;
isSearchExpanded.value = false;
}, 150);
};
// ── Search logic ──────────────────────────────────────
const hotSearchKeyword = ref(t('comp.searchBar.searchPlaceholder'));
const hotSearchValue = ref('');
const searchValue = ref('');
watch(
() => searchStore.searchValue,
(v) => {
if (v) searchValue.value = v;
},
{ immediate: true }
);
const search = () => {
const val = searchValue.value;
if (!val) {
searchValue.value = hotSearchValue.value;
return;
}
const q = { keyword: val, type: searchStore.searchType };
if (router.currentRoute.value.path === '/search-result') {
searchStore.searchValue = val;
router.replace({ path: '/search-result', query: q });
} else {
router.push({ path: '/search-result', query: q });
}
showSuggestions.value = false;
};
const selectSearchType = (key: number) => {
searchStore.searchType = key;
if (searchValue.value)
router.push({ path: '/search-result', query: { keyword: searchValue.value, type: key } });
nextTick(() => inputRef.value?.focus());
};
const rawSearchTypes = ref(SEARCH_TYPES);
const searchTypeOptions = computed(() => {
locale.value;
return rawSearchTypes.value
.filter(() => isElectron)
.map((type) => ({ label: t(type.label), key: type.key }));
});
const suggestions = ref<string[]>([]);
const showSuggestions = ref(false);
const suggestionsLoading = ref(false);
const highlightedIndex = ref(-1);
const debouncedSuggest = useDebounceFn(async (kw: string) => {
if (!kw.trim()) {
suggestions.value = [];
showSuggestions.value = false;
return;
}
suggestionsLoading.value = true;
suggestions.value = await getSearchSuggestions(kw);
suggestionsLoading.value = false;
showSuggestions.value = suggestions.value.length > 0;
highlightedIndex.value = -1;
}, 300);
const handleInput = (v: string) => debouncedSuggest(v);
const selectSuggestion = (s: string) => {
searchValue.value = s;
showSuggestions.value = false;
search();
};
const handleKeydown = (e: KeyboardEvent) => {
const len = suggestions.value.length;
if (!showSuggestions.value || !len) {
if (e.key === 'Enter') search();
return;
}
if (e.key === 'ArrowDown') {
e.preventDefault();
highlightedIndex.value = (highlightedIndex.value + 1) % len;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
highlightedIndex.value = (highlightedIndex.value - 1 + len) % len;
}
if (e.key === 'Enter') {
e.preventDefault();
highlightedIndex.value >= 0
? selectSuggestion(suggestions.value[highlightedIndex.value])
: search();
}
if (e.key === 'Escape') {
showSuggestions.value = false;
}
};
// ── User / misc ───────────────────────────────────────
const loadHotSearch = async () => {
const { data } = await getSearchKeyword();
hotSearchKeyword.value = data.data.showKeyword;
hotSearchValue.value = data.data.realkeyword;
};
const loadPage = async () => {
if (!localStorage.getItem('token')) return;
const { data } = await getUserDetail();
userStore.user =
data.profile || userStore.user || JSON.parse(localStorage.getItem('user') || '{}');
localStorage.setItem('user', JSON.stringify(userStore.user));
};
loadPage();
watchEffect(() => {
userSetOptions.value = userStore.user
? USER_SET_OPTIONS
: USER_SET_OPTIONS.filter((i) => i.key !== 'logout');
});
const restartApp = () => window.electron.ipcRenderer.send('restart');
const toLogin = () => router.push('/user');
const toGithub = () => window.open('http://donate.alger.fun/download', '_blank');
const toGithubRelease = () => {
window.location.href = 'https://donate.alger.fun/download';
};
const isDark = computed({
get: () => settingsStore.theme === 'dark',
set: () => settingsStore.toggleTheme()
});
const selectItem = (key: string) => {
switch (key) {
case 'logout':
userStore.handleLogout();
break;
case 'set':
router.push('/set');
break;
case 'user':
router.push('/user');
break;
case 'refresh':
window.location.reload();
break;
}
};
const updateInfo = ref<UpdateResult>({
hasUpdate: false,
latestVersion: '',
currentVersion: config.version,
releaseInfo: null
});
const checkForUpdates = async () => {
try {
const r = await checkUpdate(config.version);
if (r) updateInfo.value = r;
} catch (e) {
void e; // 更新检查失败时静默处理
}
};
onMounted(() => {
loadHotSearch();
loadPage();
checkForUpdates();
isElectron && initZoomFactor();
});
</script>
<style scoped>
/* ── Tab track ───────────────────────────────────────── */
.tabs-track {
position: relative;
display: inline-flex;
align-items: center;
height: 34px;
background: #f3f4f6;
border-radius: 9999px;
padding: 3px;
gap: 0;
box-sizing: border-box;
}
.dark .tabs-track {
background: #1f2937;
}
.tab-slider-bg {
position: absolute;
top: 3px;
left: 0;
height: calc(100% - 6px);
border-radius: 9999px;
background: #22c55e;
box-shadow: 0 1px 6px rgba(34, 197, 94, 0.35);
transition:
transform 0.28s cubic-bezier(0.34, 1.4, 0.64, 1),
width 0.28s cubic-bezier(0.34, 1.4, 0.64, 1);
pointer-events: none;
z-index: 0;
}
.tab-btn {
position: relative;
z-index: 1;
display: inline-flex;
align-items: center;
gap: 5px;
padding: 5px 13px;
border-radius: 9999px;
font-size: 12.5px;
font-weight: 600;
border: none;
background: transparent;
cursor: pointer;
white-space: nowrap;
transition: color 0.2s;
}
.tab-btn--on {
color: #fff;
}
.tab-btn--off {
color: #6b7280;
}
.dark .tab-btn--off {
color: #9ca3af;
}
.tab-btn--off:hover {
color: #111827;
}
.dark .tab-btn--off:hover {
color: #f9fafb;
}
/* ── Back button ─────────────────────────────────────── */
.back-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 9999px;
border: 1px solid #e5e7eb;
background: transparent;
color: #6b7280;
font-size: 16px;
cursor: pointer;
transition: all 0.15s;
}
.dark .back-btn {
border-color: #374151;
color: #9ca3af;
}
.back-btn:hover {
color: #22c55e;
border-color: #22c55e;
}
/* ── Search wrap ─────────────────────────────────────── */
.search-wrap {
transition:
flex 0.3s cubic-bezier(0.4, 0, 0.2, 1),
max-width 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.search-wrap--idle {
flex: 0 0 240px;
max-width: 240px;
}
.search-wrap--open {
flex: 1 1 0%;
max-width: 9999px;
}
.search-inner {
display: flex;
align-items: center;
gap: 6px;
height: 34px;
padding: 0 10px;
border-radius: 9999px;
border: 1.5px solid #e5e7eb;
background: #f9fafb;
transition:
border-color 0.2s,
background 0.2s,
box-shadow 0.2s;
}
.dark .search-inner {
border-color: #374151;
background: #111827;
}
.search-inner--focus {
border-color: #22c55e;
background: #fff;
box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.1);
}
.dark .search-inner--focus {
background: #0a0a0a;
box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.12);
}
.search-icon-glyph {
font-size: 14px;
color: #9ca3af;
flex-shrink: 0;
transition: color 0.2s;
}
.search-inner--focus .search-icon-glyph {
color: #22c55e;
}
.search-input {
flex: 1;
min-width: 0;
border: none;
outline: none;
background: transparent;
font-size: 13px;
color: #111827;
}
.dark .search-input {
color: #f3f4f6;
}
.search-input::placeholder {
color: #9ca3af;
}
.type-chip {
display: flex;
align-items: center;
gap: 3px;
padding: 2px 7px;
border-radius: 6px;
background: #f3f4f6;
font-size: 11px;
font-weight: 500;
color: #6b7280;
cursor: pointer;
white-space: nowrap;
transition:
background 0.15s,
color 0.15s;
flex-shrink: 0;
}
.dark .type-chip {
background: #1f2937;
color: #9ca3af;
}
.type-chip:hover {
background: #dcfce7;
color: #16a34a;
}
.dark .type-chip:hover {
background: rgba(34, 197, 94, 0.1);
color: #22c55e;
}
/* ── Action buttons ──────────────────────────────────── */
.action-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 9999px;
border: 1px solid #e5e7eb;
background: transparent;
color: #6b7280;
font-size: 15px;
cursor: pointer;
transition: all 0.15s;
}
.dark .action-btn {
border-color: #374151;
color: #9ca3af;
}
.action-btn:hover {
color: #22c55e;
border-color: #bbf7d0;
background: #f0fdf4;
}
.dark .action-btn:hover {
border-color: #166534;
background: rgba(34, 197, 94, 0.08);
color: #22c55e;
}
.action-btn.intelligence-active {
color: #ec4899;
border-color: #fbcfe8;
background: #fdf2f8;
}
.dark .action-btn.intelligence-active {
color: #ec4899;
border-color: #831843;
background: rgba(236, 72, 153, 0.1);
}
/* ── User button ─────────────────────────────────────── */
.user-btn {
display: flex;
align-items: center;
justify-content: center;
min-width: 32px;
height: 32px;
padding: 2px;
border-radius: 9999px;
border: 1px solid #e5e7eb;
background: transparent;
cursor: pointer;
transition:
border-color 0.15s,
box-shadow 0.15s;
}
.dark .user-btn {
border-color: #374151;
}
.user-btn:hover {
border-color: #22c55e;
box-shadow: 0 0 0 2px rgba(34, 197, 94, 0.12);
}
.login-label {
font-size: 12px;
font-weight: 600;
color: #6b7280;
padding: 0 8px;
}
.dark .login-label {
color: #9ca3af;
}
/* ── User menu ───────────────────────────────────────── */
.user-menu {
min-width: 220px;
border-radius: 12px;
overflow: hidden;
background: #fff;
border: 1px solid #f3f4f6;
box-shadow:
0 4px 20px rgba(0, 0, 0, 0.1),
0 1px 4px rgba(0, 0, 0, 0.05);
}
.dark .user-menu {
background: #111827;
border-color: #1f2937;
}
.user-menu-top {
display: flex;
align-items: center;
gap: 9px;
padding: 12px 14px 10px;
cursor: pointer;
transition: background 0.15s;
}
.user-menu-top:hover {
background: #f9fafb;
}
.dark .user-menu-top:hover {
background: #1f2937;
}
.user-name {
font-size: 13px;
font-weight: 600;
color: #111827;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dark .user-name {
color: #f3f4f6;
}
.menu-sep {
height: 1px;
background: #f3f4f6;
margin: 2px 0;
}
.dark .menu-sep {
background: #1f2937;
}
.menu-list {
padding: 3px 0 5px;
}
.menu-row {
display: flex;
align-items: center;
gap: 7px;
padding: 6px 14px;
font-size: 13px;
color: #374151;
cursor: pointer;
transition: background 0.12s;
}
.dark .menu-row {
color: #d1d5db;
}
.menu-row:hover {
background: #f9fafb;
}
.dark .menu-row:hover {
background: #1f2937;
}
.menu-row i {
font-size: 15px;
color: #9ca3af;
flex-shrink: 0;
width: 16px;
text-align: center;
}
.zoom-ctrl {
display: flex;
align-items: center;
gap: 3px;
}
.zoom-btn {
display: flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
border-radius: 4px;
border: none;
background: #f3f4f6;
color: #6b7280;
font-size: 10px;
cursor: pointer;
transition: all 0.12s;
}
.zoom-btn:hover {
background: #dcfce7;
color: #16a34a;
}
.zoom-val {
font-size: 11px;
font-weight: 600;
padding: 1px 6px;
border-radius: 4px;
background: #f3f4f6;
color: #6b7280;
cursor: pointer;
transition: all 0.12s;
}
.zoom-val--100 {
background: #dcfce7;
color: #16a34a;
}
.ver-chip {
font-size: 11px;
font-weight: 500;
padding: 1px 6px;
border-radius: 5px;
background: #f3f4f6;
color: #6b7280;
}
.dark .ver-chip {
background: #1f2937;
color: #9ca3af;
}
/* ── Suggestions ─────────────────────────────────────── */
.suggestions-box {
background: #fff;
}
.dark .suggestions-box {
background: #111827;
}
.suggest-row {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 14px;
font-size: 13px;
color: #374151;
cursor: pointer;
transition: background 0.1s;
}
.dark .suggest-row {
color: #d1d5db;
}
.suggest-row:hover,
.suggest-row--hi {
background: #f0fdf4;
color: #16a34a;
}
.dark .suggest-row:hover,
.dark .suggest-row--hi {
background: rgba(34, 197, 94, 0.06);
color: #22c55e;
}
.suggest-icon {
font-size: 13px;
color: #9ca3af;
flex-shrink: 0;
}
.suggest-loading {
display: flex;
justify-content: center;
padding: 12px;
}
/* ── Nav page title ──────────────────────────────────── */
.nav-page-title {
font-size: 15px;
font-weight: 700;
color: #111827;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 280px;
letter-spacing: -0.01em;
}
.dark .nav-page-title {
color: #f9fafb;
}
/* ── Transitions ─────────────────────────────────────── */
.tab-slide-enter-active,
.tab-slide-leave-active {
transition:
opacity 0.2s ease,
transform 0.2s ease;
}
.tab-slide-enter-from,
.tab-slide-leave-to {
opacity: 0;
transform: translateX(-8px);
}
.nav-title-enter-active {
transition:
opacity 0.22s ease,
transform 0.22s ease;
}
.nav-title-leave-active {
transition:
opacity 0.18s ease,
transform 0.18s ease;
}
.nav-title-enter-from {
opacity: 0;
transform: translateY(8px);
}
.nav-title-leave-to {
opacity: 0;
transform: translateY(-8px);
}
</style>