1721 lines
52 KiB
Vue
1721 lines
52 KiB
Vue
<template>
|
||
<div
|
||
class="relative overflow-hidden bg-[radial-gradient(circle_at_12%_18%,rgba(215,177,123,0.24),transparent_24%),radial-gradient(circle_at_86%_12%,rgba(120,162,182,0.18),transparent_24%),radial-gradient(circle_at_50%_100%,rgba(176,198,172,0.14),transparent_28%),linear-gradient(180deg,#efe7da_0%,#e8f0ef_44%,#f3f1e8_100%)]"
|
||
:style="contentStyle"
|
||
>
|
||
<div class="pointer-events-none absolute left-[-5rem] top-6 h-80 w-80 rounded-full bg-[rgba(234,176,107,0.45)] opacity-48 blur-[88px]"></div>
|
||
<div class="pointer-events-none absolute right-[-5rem] top-32 h-[22rem] w-[22rem] rounded-full bg-[rgba(118,176,214,0.32)] opacity-48 blur-[88px]"></div>
|
||
<div class="pointer-events-none absolute inset-y-0 left-5 right-5 opacity-14 [background-image:linear-gradient(rgba(255,255,255,0.18)_1px,transparent_1px),linear-gradient(90deg,rgba(255,255,255,0.18)_1px,transparent_1px)] [background-position:18px_0,18px_0] [background-size:36px_36px] [mask-image:linear-gradient(180deg,rgba(0,0,0,0.8),transparent_85%)]"></div>
|
||
<n-layout has-sider>
|
||
<n-layout-content class="relative !bg-transparent">
|
||
<div ref="searchRef" class="relative z-[1] hidden px-12 pt-8 lg:block">
|
||
<div ref="searchShellRef" class="search-shell relative">
|
||
<n-input-group class="glass-surface overflow-hidden rounded-[22px] transition-[box-shadow,border-color] duration-300">
|
||
<n-select
|
||
class="w-24 search-select rounded-full"
|
||
size="large"
|
||
v-model:value="broswer"
|
||
:options="options"
|
||
:menu-props="{ class: 'search-select-menu' }"
|
||
@click="cancelSbox"
|
||
/>
|
||
<n-input
|
||
ref="searchInputRef"
|
||
class="flex-1"
|
||
size="large"
|
||
autofocus
|
||
clearable
|
||
v-model:value="searchWord"
|
||
@blur="cancelSbox"
|
||
placeholder="输入关键词,按 / 快速聚焦"
|
||
>
|
||
<template #suffix>
|
||
<n-icon class=" cursor-pointer" size="large">
|
||
<icon-search />
|
||
</n-icon>
|
||
</template>
|
||
</n-input>
|
||
</n-input-group>
|
||
</div>
|
||
</div>
|
||
|
||
<Teleport to="body">
|
||
<div v-if="searchBox" class="search-suggest-portal pointer-events-none fixed inset-0 z-[1200]">
|
||
<div
|
||
class="search-suggest-panel glass-surface glass-floating-surface pointer-events-auto absolute rounded-[26px] px-3 py-3 text-sm text-slate-500"
|
||
:style="searchPanelStyle"
|
||
>
|
||
<div
|
||
v-for="(item, idx) in searchItems"
|
||
:key="idx"
|
||
class="search-suggest-item relative z-[1] flex items-center truncate rounded-[18px] border border-transparent p-3.5 pr-6 text-slate-500 transition-[transform,background-color,color,box-shadow,border-color] duration-200"
|
||
:class="selecedIdx === idx
|
||
? 'bg-[linear-gradient(135deg,#ec66ab,#f48a6f)] text-white shadow-[0_4px_10px_rgba(236,102,171,0.16),0_14px_28px_rgba(236,102,171,0.2)]'
|
||
: 'hover:-translate-y-0.5 hover:border-white/55 hover:bg-white/62 hover:text-slate-700 hover:shadow-[0_2px_6px_rgba(97,121,146,0.06),0_12px_24px_rgba(97,121,146,0.1)]'"
|
||
:style="{ marginTop: idx === 0 ? '0' : '0.45rem' }"
|
||
@click="goExtra(item.menu_link)"
|
||
@keyup.enter="goExtra(item.menu_link)"
|
||
>
|
||
<span
|
||
class="mr-2 inline-flex h-6 shrink-0 items-center rounded-full px-2.5 text-[11px] font-semibold tracking-[0.04em]"
|
||
:class="selecedIdx === idx
|
||
? 'bg-white/18 text-white'
|
||
: idx ? 'bg-slate-700/8 text-slate-500' : 'bg-[rgba(236,102,171,0.12)] text-[rgb(212,88,154)]'"
|
||
>
|
||
{{ idx ? '导航' : '搜索' }}
|
||
</span>
|
||
<span class="truncate">{{ item.menu_name }}</span>
|
||
</div>
|
||
<div v-if="searchItems.length === 1" class="search-empty py-3 text-center text-[13px] text-slate-400">
|
||
未找到匹配的导航,回车可直接搜索
|
||
</div>
|
||
<div class="search-hints mt-1.5 flex items-center justify-center gap-4 border-t border-white/55 pt-2.5 text-[11px] text-slate-400">
|
||
<span><b class="kbd">↑</b><b class="kbd">↓</b> 选择</span>
|
||
<span><b class="kbd">↵</b> 打开</span>
|
||
<span><b class="kbd">Esc</b> 关闭</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</Teleport>
|
||
|
||
<n-scrollbar class="w-full overflow-x-auto" :style="navStyle" trigger="none">
|
||
<div
|
||
v-if="navlist.length"
|
||
class="glass-surface mt-6 mb-4 flex flex-wrap items-center gap-1.5 rounded-[20px] px-2.5 py-2.5 lg:mx-12 lg:px-4"
|
||
>
|
||
<d-tag
|
||
v-for="tag in tagList"
|
||
:key="tag.name"
|
||
class="nav-tag cursor-pointer truncate rounded-full bg-white/90 px-2 py-0.5 text-[12px] font-semibold text-slate-600 shadow-sm"
|
||
:class="tag.checked ? 'text-white shadow-[0_4px_10px_rgba(15,23,42,0.12),0_10px_20px_rgba(15,23,42,0.08)]' : 'bg-white/90'"
|
||
:style="tag.checked ? { backgroundColor: tag.color || '#334155' } : undefined"
|
||
hideBeyondTags
|
||
:checked="tag.checked"
|
||
:color="tag.color"
|
||
@click="handdleTagClick(tag)"
|
||
>
|
||
<span class="inline-flex items-center gap-1">
|
||
<span
|
||
class="inline-block h-1 w-1 rounded-full ring-1 ring-white/70"
|
||
:style="{ backgroundColor: tag.color || '#94a3b8' }"
|
||
></span>
|
||
<span>{{ tag.name }}</span>
|
||
<span
|
||
class="inline-flex h-4 min-w-4 items-center justify-center rounded-full px-0.5 text-[9px] font-bold leading-none"
|
||
:class="tag.checked ? 'bg-white/25 text-white' : 'bg-slate-700/8 text-slate-400'"
|
||
>{{ tag.count ?? 0 }}</span>
|
||
</span>
|
||
</d-tag>
|
||
</div>
|
||
|
||
<TransitionGroup
|
||
ref="navcards"
|
||
name="nav-card"
|
||
tag="div"
|
||
class="grid content-start grid-cols-2 gap-5 px-3 pb-8 pt-3 lg:grid-cols-3 lg:px-12 xl:grid-cols-4 2xl:grid-cols-6"
|
||
>
|
||
<div
|
||
v-for="(item, index) in navlist"
|
||
:key="item.nid ?? index"
|
||
class="nav-card-wrap"
|
||
:data-nid="item.nid"
|
||
:style="{ '--i': index % 12 }"
|
||
>
|
||
<n-card
|
||
embedded
|
||
class="glass-nav-card h-28 box-border overflow-hidden rounded-[24px] transition-[transform,box-shadow,border-color] duration-[320ms] [transition-timing-function:cubic-bezier(0.22,1,0.36,1)] hover:-translate-y-1"
|
||
@click="goExtra(item.menu_link)"
|
||
@contextmenu.prevent="handdleContextMenu($event, item)"
|
||
>
|
||
<div class="relative mt-1 w-full flex flex-col items-center cursor-pointer group">
|
||
<icon-star
|
||
v-if="isPinned(item)"
|
||
class="nav-pin absolute -top-0.5 right-0 w-4 text-[#f0a13c] drop-shadow-[0_2px_6px_rgba(240,161,60,0.45)]"
|
||
/>
|
||
<div
|
||
v-if="item.icon_error"
|
||
:style="{ background: getItemColor(item) }"
|
||
class="grid-image flex h-10 w-10 items-center justify-center rounded-2xl border border-white/95 bg-[linear-gradient(180deg,rgba(255,255,255,0.96),rgba(234,242,249,0.92))] p-[0.2rem] text-white shadow-[0_2px_6px_rgba(97,121,146,0.06),0_10px_20px_rgba(97,121,146,0.1),inset_0_1px_0_rgba(255,255,255,0.95)] transition-transform duration-[350ms] [transition-timing-function:cubic-bezier(0.22,1,0.36,1)]"
|
||
>
|
||
{{ item.first }}
|
||
</div>
|
||
<img
|
||
v-else
|
||
class="grid-image h-10 w-10 rounded-2xl border border-white/95 bg-[linear-gradient(180deg,rgba(255,255,255,0.96),rgba(234,242,249,0.92))] p-[0.2rem] object-cover shadow-[0_2px_6px_rgba(97,121,146,0.06),0_10px_20px_rgba(97,121,146,0.1),inset_0_1px_0_rgba(255,255,255,0.95)] transition-transform duration-[350ms] [transition-timing-function:cubic-bezier(0.22,1,0.36,1)]"
|
||
:key="item.menu_icon"
|
||
:src="item.menu_icon"
|
||
:alt="item.menu_name"
|
||
@error="imgErr(index)"
|
||
/>
|
||
<div class="card-name mt-3 w-full truncate text-center text-lg !font-semibold text-slate-700">{{ item.menu_name || '' }}</div>
|
||
</div>
|
||
</n-card>
|
||
</div>
|
||
|
||
<div
|
||
v-if="addCardReady"
|
||
key="add-card"
|
||
class="nav-card-wrap"
|
||
:style="{ '--i': Math.min(navlist.length, 11) }"
|
||
>
|
||
<n-card
|
||
embedded
|
||
class="glass-nav-card glass-nav-card--warm h-28 box-border overflow-hidden rounded-[24px] transition-[transform,box-shadow,border-color] duration-[320ms] [transition-timing-function:cubic-bezier(0.22,1,0.36,1)] hover:-translate-y-1"
|
||
>
|
||
<div @click="addNav" class="w-full h-full flex flex-col items-center justify-center cursor-pointer">
|
||
<div
|
||
:style="{ background: addCardColor }"
|
||
class="add-card-plus flex h-10 w-10 items-center justify-center rounded-2xl border border-white/95 bg-[linear-gradient(180deg,rgba(255,255,255,0.96),rgba(234,242,249,0.92))] p-[0.2rem] text-2xl text-white shadow-[0_2px_6px_rgba(97,121,146,0.06),0_10px_20px_rgba(97,121,146,0.1),inset_0_1px_0_rgba(255,255,255,0.95)] transition-transform duration-300 [transition-timing-function:cubic-bezier(0.22,1,0.36,1)]"
|
||
>
|
||
+
|
||
</div>
|
||
<div class="mt-3 w-full truncate text-center text-base font-semibold text-primary">新增导航</div>
|
||
</div>
|
||
</n-card>
|
||
</div>
|
||
</TransitionGroup>
|
||
</n-scrollbar>
|
||
</n-layout-content>
|
||
|
||
<n-layout-sider width="30rem">
|
||
<homeSide />
|
||
</n-layout-sider>
|
||
</n-layout>
|
||
|
||
<maskX :visible="visible" :setVisible="navCancel">
|
||
<n-form
|
||
class="w-[500px] rounded-[28px] border border-white/80 bg-[radial-gradient(circle_at_top_left,rgba(255,199,132,0.16),transparent_34%),radial-gradient(circle_at_top_right,rgba(136,203,255,0.14),transparent_28%),linear-gradient(180deg,rgba(255,255,255,0.84),rgba(242,247,251,0.78))] p-8 shadow-[0_4px_12px_rgba(75,96,120,0.08),0_20px_44px_rgba(75,96,120,0.12),0_64px_120px_rgba(178,128,88,0.08)] backdrop-blur-[20px]"
|
||
ref="formNav"
|
||
layout="vertical"
|
||
:data="navData"
|
||
>
|
||
<div class="flex items-center justify-center gap-2 text-center text-xl font-bold tracking-[0.02em] text-slate-800">
|
||
<icon-star class="w-5 text-[#f0a13c]" />
|
||
新增导航
|
||
<icon-star class="w-5 text-[#f0a13c]" />
|
||
</div>
|
||
<n-form-item class="h-8 modal-form-item" path="menu_link">
|
||
<n-input class="modal-input" @blur="getIcon" v-model:value="navData.menu_link" placeholder="请输入链接(必填)" />
|
||
</n-form-item>
|
||
<n-form-item class="h-8 mt-4 modal-form-item" path="menu_name">
|
||
<n-input class="modal-input" v-model:value="navData.menu_name" placeholder="请输入导航名称(必填)" />
|
||
</n-form-item>
|
||
<n-form-item class="h-8 mt-4 modal-form-item" path="tag">
|
||
<n-input class="modal-input" v-model:value="navData.tag" placeholder="请输入标签(最多4个字)" />
|
||
</n-form-item>
|
||
<div v-if="quickTagOptions.length" class="mt-2 flex flex-wrap items-center gap-1.5 pl-1">
|
||
<span class="text-xs text-slate-400">常用:</span>
|
||
<span
|
||
v-for="t in quickTagOptions"
|
||
:key="t"
|
||
class="quick-tag"
|
||
:class="navData.tag === t ? 'quick-tag--active' : ''"
|
||
@click="navData.tag = t"
|
||
>{{ t }}</span>
|
||
</div>
|
||
<n-form-item class="h-8 mt-4 modal-form-item" path="menu_icon">
|
||
<n-input class="modal-input" v-model:value="navData.menu_icon" placeholder="请输入图标链接" />
|
||
<img
|
||
class="ml-5 rounded-[10px] bg-white/80 shadow-[0_8px_18px_rgba(92,118,144,0.12)]"
|
||
v-if="navData.menu_icon"
|
||
width="30"
|
||
height="30"
|
||
:src="navData.menu_icon"
|
||
alt="icon"
|
||
/>
|
||
<div v-else class="ml-5 w-[30px] h-[30px]"></div>
|
||
</n-form-item>
|
||
<div class="mt-14 flex justify-between">
|
||
<n-button class="w-[48%] !h-11 !rounded-full !border-none !bg-white/90 !font-bold !tracking-[0.04em] !text-slate-600 shadow-[0_3px_8px_rgba(89,108,130,0.07),0_12px_24px_rgba(89,108,130,0.09)]" secondary variant="solid" @click="navCancel">取消</n-button>
|
||
<n-button class="w-[48%] !h-11 !rounded-full !border-none !bg-[linear-gradient(135deg,#ec66ab,#f48a6f)] !font-bold !tracking-[0.04em] shadow-[0_4px_10px_rgba(236,102,171,0.18),0_14px_28px_rgba(236,102,171,0.2)]" type="primary" variant="solid" @click="navSubmit">确定</n-button>
|
||
</div>
|
||
</n-form>
|
||
</maskX>
|
||
|
||
<aplayer />
|
||
|
||
<contextMenu :show="menuShow" :options="MenuOptions">
|
||
<div class="menu-pop glass-floating-surface !m-0 cursor-pointer !rounded-2xl !p-0" @mouseenter="removeTimer" @mouseleave="hideMenu">
|
||
<div class="menu-head truncate">{{ currentItem?.menu_name || '导航操作' }}</div>
|
||
<div class="menu-item" @click="handdleMenuItem(1)">
|
||
<icon-pen class="menu-icon" />修改名称
|
||
</div>
|
||
<div class="menu-item" @click="handdleMenuItem(2)">
|
||
<icon-loc class="menu-icon" />修改链接
|
||
</div>
|
||
<div class="menu-item" @click="handdleMenuItem(3)">
|
||
<icon-tag class="menu-icon" />修改标签
|
||
</div>
|
||
<div class="menu-divider"></div>
|
||
<div class="menu-item" @click="handdleMenuItem(5)">
|
||
<icon-download class="menu-icon" />复制链接
|
||
</div>
|
||
<div class="menu-item" @click="handdleMenuItem(6)">
|
||
<icon-star class="menu-icon" :class="isPinned(currentItem) ? '!text-[#f0a13c]' : ''" />{{ isPinned(currentItem) ? '取消置顶' : '置顶导航' }}
|
||
</div>
|
||
<div class="menu-item" @click="handdleMenuItem(7)">
|
||
<icon-loading class="menu-icon" />重新加载图标
|
||
</div>
|
||
<div class="menu-divider"></div>
|
||
<div class="menu-item menu-item--danger" @click="handdleMenuItem(4)">
|
||
删除导航
|
||
</div>
|
||
</div>
|
||
</contextMenu>
|
||
|
||
<maskX :visible="editModal" :setVisible="handleItemCancel">
|
||
<div class="w-[500px] rounded-[28px] border border-white/80 bg-[radial-gradient(circle_at_top_left,rgba(255,199,132,0.16),transparent_34%),radial-gradient(circle_at_top_right,rgba(136,203,255,0.14),transparent_28%),linear-gradient(180deg,rgba(255,255,255,0.84),rgba(242,247,251,0.78))] p-8 shadow-[0_4px_12px_rgba(75,96,120,0.08),0_20px_44px_rgba(75,96,120,0.12),0_64px_120px_rgba(178,128,88,0.08)] backdrop-blur-[20px]">
|
||
<div class="mb-8 flex items-center justify-center gap-2 text-center text-xl font-bold tracking-[0.02em] text-slate-800">
|
||
<icon-pen class="w-5 text-primary" />
|
||
修改导航内容
|
||
</div>
|
||
<div class="mb-4 text-slate-500">
|
||
原内容:
|
||
<span class="text-primary" v-if="currentClickedItem === 1">导航名称:{{ currentItem?.menu_name }}</span>
|
||
<span class="text-primary" v-else-if="currentClickedItem === 2">导航链接:{{ currentItem?.menu_link }}</span>
|
||
<span class="text-primary" v-else-if="currentClickedItem === 3">导航标签:{{ currentItem?.tag }}</span>
|
||
</div>
|
||
<n-input class="modal-input" v-model:value="editInput" placeholder="请输入修改内容" @keyup.enter="handleItemSubmit" />
|
||
<div class="mt-8 flex justify-between">
|
||
<n-button class="w-[48%] !h-11 !rounded-full !border-none !bg-white/90 !font-bold !tracking-[0.04em] !text-slate-600 shadow-[0_3px_8px_rgba(89,108,130,0.07),0_12px_24px_rgba(89,108,130,0.09)]" secondary @click="handleItemCancel">取消</n-button>
|
||
<n-button class="w-[48%] !h-11 !rounded-full !border-none !bg-[linear-gradient(135deg,#ec66ab,#f48a6f)] !font-bold !tracking-[0.04em] shadow-[0_4px_10px_rgba(236,102,171,0.18),0_14px_28px_rgba(236,102,171,0.2)]" type="primary" @click="handleItemSubmit">确定</n-button>
|
||
</div>
|
||
</div>
|
||
</maskX>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import contextMenu from '@/components/contextMenu.vue'
|
||
import maskX from '@/components/mask.vue'
|
||
import { deepclone } from '@/util/index.ts'
|
||
|
||
definePage({
|
||
name: 'home',
|
||
alias: '/home',
|
||
meta: {
|
||
title: '首页',
|
||
}
|
||
})
|
||
|
||
interface NavItem {
|
||
nid?: number
|
||
menu_link: string
|
||
menu_name: string
|
||
tag: string
|
||
menu_icon: string
|
||
icon_error?: boolean
|
||
first?: string
|
||
color?: string
|
||
}
|
||
|
||
interface TagItem {
|
||
name: string
|
||
color: string
|
||
checked: boolean
|
||
count: number
|
||
}
|
||
|
||
interface SearchItem {
|
||
menu_name: string
|
||
menu_link: string
|
||
tag: string
|
||
}
|
||
|
||
interface SearchEngineOption {
|
||
label: string
|
||
value: string
|
||
url: string
|
||
}
|
||
|
||
/** localStorage 键名 */
|
||
const HITS_KEY = 'nav_hits'
|
||
const PINNED_KEY = 'nav_pinned'
|
||
const ENGINE_KEY = 'nav_search_engine'
|
||
|
||
const menuShow = ref(false)
|
||
const MenuOptions = reactive({ x: 0, y: 0 })
|
||
const currentItem = ref<NavItem | null>(null)
|
||
const currentClickedItem = ref(0)
|
||
const editModal = ref(false)
|
||
const editInput = ref('')
|
||
const searchInputRef = ref<HTMLInputElement | null>(null)
|
||
const pinnedIds = ref<string[]>(getPinned())
|
||
// 新增导航卡片延迟挂载,与导航列表一起交错入场
|
||
const addCardReady = ref(false)
|
||
|
||
const visible = ref(false)
|
||
const navData = reactive<NavItem>({
|
||
menu_link: '',
|
||
menu_name: '',
|
||
tag: '',
|
||
menu_icon: ''
|
||
})
|
||
const formNav = ref<any>(null)
|
||
const navcards = ref<any>(null)
|
||
const searchRef = useTemplateRef('searchRef')
|
||
const searchShellRef = useTemplateRef('searchShellRef')
|
||
|
||
const nav: any = $store.nav.useNavStore()
|
||
const usrLog = $store.log.useLogStore()
|
||
|
||
const contentStyle = ref<Record<string, string>>({})
|
||
const navStyle = ref<Record<string, string>>({})
|
||
const searchPanelStyle = ref<Record<string, string>>({})
|
||
const searchWord = ref('')
|
||
const broswer = ref(localStorage.getItem(ENGINE_KEY) || 'bing')
|
||
const searchItems = ref<SearchItem[]>([])
|
||
const selecedIdx = ref(0)
|
||
const searchBox = ref(false)
|
||
const navlist = ref<NavItem[]>([])
|
||
const tagList = ref<TagItem[]>([])
|
||
const options = ref<SearchEngineOption[]>([
|
||
{
|
||
label: '必应',
|
||
value: 'bing',
|
||
url: 'https://cn.bing.com/search?q='
|
||
},
|
||
{
|
||
label: '百度',
|
||
value: 'baidu',
|
||
url: 'https://www.baidu.com/s?wd='
|
||
},
|
||
{
|
||
label: '谷歌',
|
||
value: 'google',
|
||
url: 'https://www.google.com/search?q='
|
||
},
|
||
{
|
||
label: '翻译',
|
||
value: 'trans',
|
||
url: 'https://translate.volcengine.com?text='
|
||
},
|
||
])
|
||
|
||
let cloneNavlist: NavItem[] = []
|
||
let timer: ReturnType<typeof setTimeout> | null = null
|
||
const defaultTagName = '全部'
|
||
const addCardColor = getRandomDarkColor()
|
||
|
||
const currentSearchOption = computed(() => {
|
||
return options.value.find(item => item.value === broswer.value) ?? options.value[0]
|
||
})
|
||
|
||
// 常用标签(新增导航弹窗快捷选择)
|
||
const quickTagOptions = computed(() => {
|
||
const set = new Set<string>()
|
||
cloneNavlist.forEach(item => {
|
||
if (item.tag) set.add(item.tag)
|
||
})
|
||
return Array.from(set).slice(0, 8)
|
||
})
|
||
|
||
function isPinned(item?: NavItem | null): boolean {
|
||
if (!item?.nid) return false
|
||
return pinnedIds.value.includes(String(item.nid))
|
||
}
|
||
|
||
// 记忆搜索引擎选择
|
||
watch(broswer, (v) => {
|
||
localStorage.setItem(ENGINE_KEY, v)
|
||
})
|
||
|
||
watch(searchWord, handleInput)
|
||
watch(() => usrLog.isLogin, (newVal: any) => {
|
||
if (newVal) {
|
||
getNavList()
|
||
} else {
|
||
navlist.value = []
|
||
cloneNavlist = []
|
||
resetTagList()
|
||
}
|
||
})
|
||
|
||
function formatTag(tag: string) {
|
||
return (tag || '').slice(0, 4)
|
||
}
|
||
|
||
function createDefaultTag(): TagItem {
|
||
return {
|
||
name: defaultTagName,
|
||
color: getRandomDarkColor(30, 128),
|
||
checked: true,
|
||
count: 0
|
||
}
|
||
}
|
||
|
||
// ---------- 本地访问统计与置顶 ----------
|
||
function getHitsMap(): Record<string, number> {
|
||
try {
|
||
return JSON.parse(localStorage.getItem(HITS_KEY) || '{}')
|
||
} catch {
|
||
return {}
|
||
}
|
||
}
|
||
|
||
function getPinned(): string[] {
|
||
try {
|
||
const list = JSON.parse(localStorage.getItem(PINNED_KEY) || '[]')
|
||
return Array.isArray(list) ? list : []
|
||
} catch {
|
||
return []
|
||
}
|
||
}
|
||
|
||
function hitNav(item: NavItem) {
|
||
if (!item.nid) return
|
||
const key = String(item.nid)
|
||
const hits = getHitsMap()
|
||
hits[key] = (hits[key] || 0) + 1
|
||
localStorage.setItem(HITS_KEY, JSON.stringify(hits))
|
||
// 轻微延迟后按热度重排(配合 TransitionGroup 平滑过渡)
|
||
window.setTimeout(() => {
|
||
navlist.value = sortNavList(navlist.value)
|
||
}, 320)
|
||
}
|
||
|
||
function togglePin() {
|
||
if (!currentItem.value?.nid) return
|
||
const key = String(currentItem.value.nid)
|
||
let pins = getPinned()
|
||
if (pins.includes(key)) {
|
||
pins = pins.filter(p => p !== key)
|
||
$msg.info('已取消置顶')
|
||
} else {
|
||
pins.unshift(key)
|
||
$msg.success('已置顶,将优先展示')
|
||
}
|
||
localStorage.setItem(PINNED_KEY, JSON.stringify(pins))
|
||
pinnedIds.value = pins
|
||
navlist.value = sortNavList(navlist.value)
|
||
}
|
||
|
||
function cleanupNavLocal(nid?: number) {
|
||
if (!nid) return
|
||
const key = String(nid)
|
||
pinnedIds.value = pinnedIds.value.filter(p => p !== key)
|
||
localStorage.setItem(PINNED_KEY, JSON.stringify(pinnedIds.value))
|
||
const hits = getHitsMap()
|
||
if (hits[key] !== undefined) {
|
||
delete hits[key]
|
||
localStorage.setItem(HITS_KEY, JSON.stringify(hits))
|
||
}
|
||
}
|
||
|
||
function sortNavList(list: NavItem[]): NavItem[] {
|
||
const hits = getHitsMap()
|
||
const pins = getPinned()
|
||
return [...list].sort((a, b) => {
|
||
const aPin = pins.includes(String(a.nid)) ? 1 : 0
|
||
const bPin = pins.includes(String(b.nid)) ? 1 : 0
|
||
if (aPin !== bPin) return bPin - aPin
|
||
return (hits[String(b.nid)] || 0) - (hits[String(a.nid)] || 0)
|
||
})
|
||
}
|
||
|
||
// ---------- 手动 FLIP 重排动画 ----------
|
||
// TransitionGroup 的内置 move 在"有删除"场景下会因 leave 元素占位导致位置计算错误,
|
||
// 这里在数据变化后自己实现 FLIP:记录旧位置 → 等待渲染与 leave 移除(双 rAF)→ 动画到新位置
|
||
const flipRects = new Map<string, DOMRect>()
|
||
|
||
function recordFlipPositions() {
|
||
flipRects.clear()
|
||
const el = navcards.value?.$el as HTMLElement | undefined
|
||
if (!el) return
|
||
el.querySelectorAll<HTMLElement>('.nav-card-wrap').forEach(wrap => {
|
||
const nid = wrap.getAttribute('data-nid')
|
||
if (nid) flipRects.set(nid, wrap.getBoundingClientRect())
|
||
})
|
||
}
|
||
|
||
function playFlip() {
|
||
// 三 rAF:确保渲染完成 + 被删除的卡片(leave 流程)已从 DOM 移除后再计算位置。
|
||
// 若只等两帧,FLIP 会与 leave 移除同帧竞争(FLIP 先执行),
|
||
// 被删卡片仍占位导致剩余卡片的新位置计算错误(dx=0),动画被跳过。
|
||
requestAnimationFrame(() => requestAnimationFrame(() => requestAnimationFrame(() => {
|
||
const el = navcards.value?.$el as HTMLElement | undefined
|
||
if (!el) return
|
||
|
||
el.querySelectorAll<HTMLElement>('.nav-card-wrap').forEach(wrap => {
|
||
const nid = wrap.getAttribute('data-nid')
|
||
const old = nid ? flipRects.get(nid) : undefined
|
||
if (!old) return
|
||
|
||
const now = wrap.getBoundingClientRect()
|
||
const dx = old.left - now.left
|
||
const dy = old.top - now.top
|
||
if (!dx && !dy) return
|
||
|
||
// Invert:瞬移到旧位置
|
||
wrap.style.transition = 'none'
|
||
wrap.style.transform = `translate(${dx}px, ${dy}px)`
|
||
wrap.getBoundingClientRect() // 强制 reflow
|
||
|
||
// Play:恢复过渡,动画到新位置
|
||
wrap.style.transition = 'transform 0.45s cubic-bezier(0.22, 1, 0.36, 1)'
|
||
wrap.style.transform = ''
|
||
|
||
const cleanup = () => {
|
||
wrap.style.transition = ''
|
||
wrap.style.transform = ''
|
||
wrap.removeEventListener('transitionend', cleanup)
|
||
}
|
||
wrap.addEventListener('transitionend', cleanup)
|
||
// 兜底清理
|
||
window.setTimeout(cleanup, 800)
|
||
})
|
||
})))
|
||
}
|
||
|
||
// pre-flush:DOM 更新前记录旧位置
|
||
watch(navlist, () => {
|
||
recordFlipPositions()
|
||
playFlip()
|
||
})
|
||
|
||
function resetTagList() {
|
||
tagList.value = [createDefaultTag()]
|
||
}
|
||
|
||
function cancelSbox() {
|
||
window.setTimeout(() => {
|
||
searchBox.value = false
|
||
searchItems.value = []
|
||
selecedIdx.value = 0
|
||
}, 200)
|
||
}
|
||
|
||
function handleInput() {
|
||
const keyword = searchWord.value.trim().toLowerCase()
|
||
if (!keyword) {
|
||
searchItems.value = []
|
||
searchBox.value = false
|
||
selecedIdx.value = 0
|
||
return
|
||
}
|
||
|
||
searchBox.value = true
|
||
selecedIdx.value = 0
|
||
const matchedItems = navlist.value.filter((item) =>
|
||
item.menu_name?.toLowerCase().includes(keyword) ||
|
||
item.menu_link?.toLowerCase().includes(keyword) ||
|
||
formatTag(item.tag).toLowerCase().includes(keyword)
|
||
)
|
||
|
||
searchItems.value = [
|
||
{
|
||
menu_name: `在${currentSearchOption.value.label}中搜索“${searchWord.value}”`,
|
||
menu_link: `${currentSearchOption.value.url}${searchWord.value}`,
|
||
tag: ''
|
||
},
|
||
...matchedItems.map(item => ({
|
||
menu_name: item.menu_name,
|
||
menu_link: item.menu_link,
|
||
tag: item.tag,
|
||
}))
|
||
]
|
||
|
||
updateSearchPanelPosition()
|
||
}
|
||
|
||
function handleWindowKeydown(e: KeyboardEvent) {
|
||
if (e.isComposing) return
|
||
const target = e.target as HTMLElement | null
|
||
const isTyping = !!target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)
|
||
|
||
// Esc:逐层关闭浮层
|
||
if (e.key === 'Escape') {
|
||
if (menuShow.value) {
|
||
menuShow.value = false
|
||
return
|
||
}
|
||
if (editModal.value) {
|
||
handleItemCancel()
|
||
return
|
||
}
|
||
if (visible.value) {
|
||
navCancel()
|
||
return
|
||
}
|
||
if (searchBox.value) {
|
||
searchBox.value = false
|
||
searchItems.value = []
|
||
selecedIdx.value = 0
|
||
searchInputRef.value?.blur()
|
||
}
|
||
return
|
||
}
|
||
|
||
// Ctrl/Cmd + K 或 非输入状态下按 /:聚焦搜索框
|
||
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k') {
|
||
e.preventDefault()
|
||
searchInputRef.value?.focus()
|
||
return
|
||
}
|
||
if (e.key === '/' && !isTyping) {
|
||
e.preventDefault()
|
||
searchInputRef.value?.focus()
|
||
return
|
||
}
|
||
|
||
// 搜索建议面板键盘导航
|
||
if (!searchBox.value) return
|
||
|
||
if (e.key === 'ArrowDown') {
|
||
e.preventDefault()
|
||
selecedIdx.value = selecedIdx.value < searchItems.value.length - 1 ? selecedIdx.value + 1 : 0
|
||
return
|
||
}
|
||
|
||
if (e.key === 'ArrowUp') {
|
||
e.preventDefault()
|
||
selecedIdx.value = selecedIdx.value > 0 ? selecedIdx.value - 1 : searchItems.value.length - 1
|
||
return
|
||
}
|
||
|
||
if (e.key === 'Enter') {
|
||
// 弹窗打开时不拦截回车
|
||
if (editModal.value || visible.value) return
|
||
e.preventDefault()
|
||
if (searchItems.value.length > 0) {
|
||
goExtra(searchItems.value[selecedIdx.value].menu_link)
|
||
searchBox.value = false
|
||
selecedIdx.value = 0
|
||
} else if (searchWord.value.trim()) {
|
||
search()
|
||
}
|
||
}
|
||
}
|
||
|
||
const hideMenu = () => {
|
||
removeTimer()
|
||
timer = window.setTimeout(() => {
|
||
menuShow.value = false
|
||
}, 2000)
|
||
}
|
||
|
||
const removeTimer = () => {
|
||
if (timer) {
|
||
clearTimeout(timer)
|
||
timer = null
|
||
}
|
||
}
|
||
|
||
function handdleMenuItem(type: number) {
|
||
menuShow.value = false
|
||
currentClickedItem.value = type
|
||
|
||
if (type === 4) {
|
||
if (!currentItem.value) return
|
||
|
||
$modal({
|
||
title: '删除导航',
|
||
content: `确定要删除【${currentItem.value.menu_name}】吗?删除后不可恢复。`,
|
||
cancelText: '取消',
|
||
submitText: '删除',
|
||
handdleSubmit: async () => {
|
||
try {
|
||
const res = await $http.nav.deleteNav(currentItem.value?.nid)
|
||
if (res.code === 200) {
|
||
$msg.success('删除成功')
|
||
cleanupNavLocal(currentItem.value?.nid)
|
||
getNavList()
|
||
}
|
||
} catch {
|
||
$msg.error('删除失败,请稍后再试')
|
||
}
|
||
}
|
||
})
|
||
return
|
||
}
|
||
|
||
// 复制链接
|
||
if (type === 5) {
|
||
copyLink()
|
||
return
|
||
}
|
||
|
||
// 置顶 / 取消置顶
|
||
if (type === 6) {
|
||
togglePin()
|
||
return
|
||
}
|
||
|
||
// 重新加载图标
|
||
if (type === 7) {
|
||
retryIcon()
|
||
return
|
||
}
|
||
|
||
editModal.value = true
|
||
}
|
||
|
||
async function copyLink() {
|
||
const link = currentItem.value?.menu_link
|
||
if (!link) return
|
||
|
||
try {
|
||
await navigator.clipboard.writeText(link)
|
||
$msg.success('链接已复制')
|
||
} catch {
|
||
// 兼容非 https 环境
|
||
const textarea = document.createElement('textarea')
|
||
textarea.value = link
|
||
textarea.style.position = 'fixed'
|
||
textarea.style.opacity = '0'
|
||
document.body.appendChild(textarea)
|
||
textarea.select()
|
||
document.execCommand('copy')
|
||
document.body.removeChild(textarea)
|
||
$msg.success('链接已复制')
|
||
}
|
||
}
|
||
|
||
async function retryIcon() {
|
||
const item = currentItem.value
|
||
if (!item?.menu_link) return
|
||
|
||
try {
|
||
const res = await $http.mix.getIcon({ url: item.menu_link })
|
||
if (res.code === 200 && res.data?.url) {
|
||
item.menu_icon = res.data.url
|
||
item.icon_error = false
|
||
const target = navlist.value.find(i => i.nid === item.nid)
|
||
if (target) {
|
||
target.menu_icon = res.data.url
|
||
target.icon_error = false
|
||
}
|
||
$msg.success('图标已更新')
|
||
} else {
|
||
$msg.error('图标获取失败,可手动填写图标链接')
|
||
}
|
||
} catch {
|
||
$msg.error('图标获取失败,可手动填写图标链接')
|
||
}
|
||
}
|
||
|
||
function handleItemCancel() {
|
||
editModal.value = false
|
||
editInput.value = ''
|
||
}
|
||
|
||
async function handleItemSubmit() {
|
||
const value = editInput.value.trim()
|
||
if (!value) {
|
||
$msg.error('请输入修改内容')
|
||
return
|
||
}
|
||
|
||
if (!currentItem.value?.nid) return
|
||
|
||
const updateData: Partial<NavItem> = {}
|
||
if (currentClickedItem.value === 1) {
|
||
updateData.menu_name = value
|
||
} else if (currentClickedItem.value === 2) {
|
||
updateData.menu_link = value
|
||
} else if (currentClickedItem.value === 3) {
|
||
updateData.tag = formatTag(value)
|
||
}
|
||
|
||
try {
|
||
const res = await $http.nav.editNav(currentItem.value.nid, updateData)
|
||
if (res.code === 200) {
|
||
$msg.success('修改成功')
|
||
handleItemCancel()
|
||
getNavList()
|
||
}
|
||
} catch {
|
||
$msg.error('修改失败,请稍后再试')
|
||
}
|
||
}
|
||
|
||
async function getNavList() {
|
||
const userinfo = $cookies.get('userinfo')
|
||
if (!userinfo) {
|
||
navlist.value = []
|
||
cloneNavlist = []
|
||
resetTagList()
|
||
addCardReady.value = true
|
||
return
|
||
}
|
||
|
||
try {
|
||
const res = await $http.nav.getNavList()
|
||
addCardReady.value = true
|
||
if (!res.data) return
|
||
|
||
resetTagList()
|
||
const countMap: Record<string, number> = {}
|
||
res.data.forEach((item: NavItem) => {
|
||
const tag = formatTag(item.tag)
|
||
countMap[tag] = (countMap[tag] || 0) + 1
|
||
})
|
||
|
||
const normalizedList = res.data.map((item: NavItem) => {
|
||
const normalizedTag = formatTag(item.tag)
|
||
|
||
if (!tagList.value.find(tag => tag.name === normalizedTag)) {
|
||
tagList.value.push({
|
||
name: normalizedTag,
|
||
color: getRandomDarkColor(30, 128),
|
||
checked: false,
|
||
count: 0
|
||
})
|
||
}
|
||
|
||
return {
|
||
...item,
|
||
tag: normalizedTag,
|
||
icon_error: false,
|
||
first: item.menu_name.charAt(0) || '',
|
||
color: getRandomDarkColor()
|
||
}
|
||
})
|
||
|
||
// 统计各标签数量
|
||
tagList.value.forEach(tag => {
|
||
tag.count = tag.name === defaultTagName ? normalizedList.length : (countMap[tag.name] || 0)
|
||
})
|
||
|
||
cloneNavlist = deepclone(normalizedList)
|
||
navlist.value = sortNavList(normalizedList)
|
||
} catch {
|
||
addCardReady.value = true
|
||
}
|
||
}
|
||
|
||
function getItemColor(item: NavItem) {
|
||
const tag = tagList.value.find((currentTag) => currentTag.name === formatTag(item.tag))
|
||
return tag ? tag.color : getRandomDarkColor(30, 128)
|
||
}
|
||
|
||
function handdleTagClick(tag: TagItem) {
|
||
if(tag.name == "全部" && tag.checked == true) return
|
||
tag.checked = !tag.checked
|
||
|
||
if (tag.name === defaultTagName) {
|
||
tagList.value.forEach((currentTag) => {
|
||
if (currentTag.name !== defaultTagName) {
|
||
currentTag.checked = false
|
||
}
|
||
})
|
||
navlist.value = sortNavList(cloneNavlist)
|
||
return
|
||
}
|
||
|
||
const allTag = tagList.value.find((currentTag) => currentTag.name === defaultTagName)
|
||
if (allTag) allTag.checked = false
|
||
|
||
const selectedTags = tagList.value
|
||
.filter((currentTag) => currentTag.checked && currentTag.name !== defaultTagName)
|
||
.map((currentTag) => currentTag.name)
|
||
|
||
if (selectedTags.length === 0) {
|
||
if (allTag) allTag.checked = true
|
||
navlist.value = sortNavList(cloneNavlist)
|
||
return
|
||
}
|
||
|
||
navlist.value = sortNavList(cloneNavlist.filter((item) => selectedTags.includes(formatTag(item.tag))))
|
||
}
|
||
|
||
function imgErr(index: number) {
|
||
if (navlist.value[index]) {
|
||
navlist.value[index].icon_error = true
|
||
}
|
||
}
|
||
|
||
function goExtra(link: string) {
|
||
// 记录点击热度
|
||
const matched = navlist.value.find(item => item.menu_link === link)
|
||
if (matched) hitNav(matched)
|
||
window.open(link, '_blank')
|
||
}
|
||
|
||
function updateSearchPanelPosition() {
|
||
window.requestAnimationFrame(() => {
|
||
const rect = searchShellRef.value?.getBoundingClientRect()
|
||
if (!rect) return
|
||
|
||
const viewportPadding = 24
|
||
const offsetLeft = 112
|
||
const left = Math.max(viewportPadding, rect.left + offsetLeft)
|
||
const availableWidth = Math.max(280, window.innerWidth - left - viewportPadding)
|
||
const width = Math.min(672, availableWidth, Math.max(320, rect.width - offsetLeft))
|
||
const availableHeight = Math.max(180, window.innerHeight - rect.bottom - 32)
|
||
|
||
searchPanelStyle.value = {
|
||
left: `${Math.round(left)}px`,
|
||
top: `${Math.round(rect.bottom + 14)}px`,
|
||
width: `${Math.round(width)}px`,
|
||
maxHeight: `${Math.round(Math.min(availableHeight, 420))}px`
|
||
}
|
||
})
|
||
}
|
||
|
||
function search() {
|
||
const keyword = searchWord.value.trim()
|
||
if (!keyword) return
|
||
goExtra(`${currentSearchOption.value.url}${keyword}`)
|
||
}
|
||
|
||
function getRandomDarkColor(min = 30, max = 128): string {
|
||
const normalizedMin = Math.max(0, min)
|
||
const normalizedMax = Math.max(normalizedMin + 1, Math.min(255, max))
|
||
const randomChannel = () => Math.floor(Math.random() * (normalizedMax - normalizedMin + 1)) + normalizedMin
|
||
const toHex = (value: number) => value.toString(16).padStart(2, '0')
|
||
|
||
return `#${toHex(randomChannel())}${toHex(randomChannel())}${toHex(randomChannel())}`
|
||
}
|
||
|
||
function addNav() {
|
||
if (!$cookies.get('token')) {
|
||
$msg.error('请先登录')
|
||
return
|
||
}
|
||
visible.value = true
|
||
}
|
||
|
||
async function getIcon() {
|
||
if (!navData.menu_link) return
|
||
|
||
try {
|
||
const res = await $http.mix.getIcon({
|
||
url: navData.menu_link
|
||
})
|
||
|
||
if (res.code === 200) {
|
||
navData.menu_icon = res.data.url
|
||
}
|
||
} catch {
|
||
$msg.error('图标获取失败')
|
||
}
|
||
}
|
||
|
||
function resetNav() {
|
||
navData.menu_link = ''
|
||
navData.menu_name = ''
|
||
navData.tag = ''
|
||
navData.menu_icon = ''
|
||
}
|
||
|
||
function navCancel() {
|
||
visible.value = false
|
||
resetNav()
|
||
}
|
||
|
||
async function navSubmit() {
|
||
if (!navData.menu_link || !navData.menu_name || !navData.tag) {
|
||
$msg.error('请输入链接、标签和名称')
|
||
return
|
||
}
|
||
|
||
navData.tag = formatTag(navData.tag)
|
||
|
||
try {
|
||
const res = await $http.nav.addNav(navData)
|
||
|
||
if (res.code === 200) {
|
||
$msg.success('添加成功')
|
||
visible.value = false
|
||
getNavList()
|
||
}
|
||
} catch {
|
||
$msg.error('添加失败,请稍后再试')
|
||
}
|
||
|
||
resetNav()
|
||
}
|
||
|
||
function handdleContextMenu(event: MouseEvent, item: NavItem) {
|
||
menuShow.value = true
|
||
MenuOptions.x = event.clientX
|
||
MenuOptions.y = event.clientY
|
||
currentItem.value = item
|
||
}
|
||
|
||
const handleResize = () => {
|
||
const screenHeight = window.innerHeight
|
||
|
||
contentStyle.value = {
|
||
height: `${screenHeight - nav.navH}px`
|
||
}
|
||
const searchHeight: number = searchRef.value?.getBoundingClientRect().y || 0
|
||
const searchH: number = searchRef.value?.getBoundingClientRect().height || 0
|
||
navStyle.value = {
|
||
height: `${window.innerHeight - (searchHeight + searchH)}px`
|
||
}
|
||
|
||
if (searchBox.value) {
|
||
updateSearchPanelPosition()
|
||
}
|
||
}
|
||
|
||
onMounted(() => {
|
||
resetTagList()
|
||
contentStyle.value = {
|
||
height: `${window.innerHeight - nav.navH}px`
|
||
}
|
||
const navcardsEl = navcards.value?.$el as HTMLElement | undefined
|
||
navStyle.value = {
|
||
height: `${window.innerHeight - (navcardsEl?.getBoundingClientRect().y || 0)}px`
|
||
}
|
||
searchInputRef.value?.focus()
|
||
|
||
|
||
getNavList()
|
||
// 兜底:即使导航请求异常,也保证新增卡片最终出现
|
||
window.setTimeout(() => {
|
||
addCardReady.value = true
|
||
}, 1200)
|
||
window.addEventListener('resize', handleResize)
|
||
window.addEventListener('scroll', handleResize, true)
|
||
window.addEventListener('keydown', handleWindowKeydown)
|
||
})
|
||
|
||
onBeforeUnmount(() => {
|
||
window.removeEventListener('resize', handleResize)
|
||
window.removeEventListener('scroll', handleResize, true)
|
||
window.removeEventListener('keydown', handleWindowKeydown)
|
||
removeTimer()
|
||
})
|
||
|
||
watch(searchBox, (visible) => {
|
||
if (visible) {
|
||
updateSearchPanelPosition()
|
||
}
|
||
})
|
||
</script>
|
||
|
||
<style scoped lang="less">
|
||
.modal-form-item {
|
||
:deep(.n-form-item-feedback-wrapper) {
|
||
min-height: 18px;
|
||
}
|
||
}
|
||
|
||
:deep(.n-layout) {
|
||
background: transparent !important;
|
||
}
|
||
|
||
:deep(.n-layout-content) {
|
||
background: transparent !important;
|
||
}
|
||
|
||
:deep(.n-layout-sider) {
|
||
background: transparent !important;
|
||
border-left: none !important;
|
||
box-shadow: none !important;
|
||
}
|
||
|
||
:deep(.n-base-selection) {
|
||
border: none !important;
|
||
box-shadow: none !important;
|
||
background: transparent !important;
|
||
}
|
||
|
||
:deep(.search-select .n-base-selection) {
|
||
border-radius: 9999px !important;
|
||
background: transparent !important;
|
||
padding-left: 0.2rem;
|
||
padding-right: 0.2rem;
|
||
min-height: 40px !important;
|
||
position: relative;
|
||
}
|
||
|
||
:deep(.search-select .n-base-selection__border),
|
||
:deep(.search-select .n-base-selection__state-border) {
|
||
display: none !important;
|
||
}
|
||
|
||
:deep(.search-select .n-base-selection-overlay) {
|
||
background: transparent !important;
|
||
border-radius: 9999px !important;
|
||
}
|
||
|
||
:deep(.search-select .n-base-selection-label) {
|
||
justify-content: center;
|
||
min-height: 2.5rem !important;
|
||
border-radius: 9999px !important;
|
||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.28), rgba(255, 255, 255, 0.08)) !important;
|
||
box-shadow:
|
||
inset 0 1px 0 rgba(255, 255, 255, 0.36),
|
||
inset -1px 0 0 rgba(255, 255, 255, 0.1);
|
||
padding-right: 0.25rem !important;
|
||
}
|
||
|
||
:deep(.search-select .n-base-selection-input) {
|
||
font-weight: 700;
|
||
color: #42556b !important;
|
||
letter-spacing: 0.04em;
|
||
}
|
||
|
||
:deep(.search-select .n-base-selection__suffix) {
|
||
color: #6d839a !important;
|
||
margin-right: 0.1rem;
|
||
}
|
||
|
||
:deep(.n-input) {
|
||
--n-border: none !important;
|
||
--n-border-hover: none !important;
|
||
--n-border-focus: none !important;
|
||
--n-box-shadow-focus: none !important;
|
||
background: transparent !important;
|
||
}
|
||
|
||
:deep(.n-input__input-el),
|
||
:deep(.n-base-selection-label) {
|
||
color: #314155 !important;
|
||
}
|
||
|
||
.glass-surface,
|
||
.glass-nav-card {
|
||
position: relative;
|
||
border: 1px solid rgba(255, 255, 255, 0.58) !important;
|
||
background:
|
||
linear-gradient(135deg, rgba(255, 255, 255, 0.34), rgba(255, 255, 255, 0.16)),
|
||
linear-gradient(180deg, rgba(241, 248, 252, 0.2), rgba(224, 236, 245, 0.08)) !important;
|
||
box-shadow:
|
||
0 1px 3px rgba(97, 121, 146, 0.05),
|
||
0 14px 30px rgba(97, 121, 146, 0.09),
|
||
0 36px 72px rgba(178, 128, 88, 0.07),
|
||
inset 0 1px 0 rgba(255, 255, 255, 0.72),
|
||
inset 0 -1px 0 rgba(255, 255, 255, 0.14) !important;
|
||
backdrop-filter: blur(24px) saturate(165%);
|
||
-webkit-backdrop-filter: blur(24px) saturate(165%);
|
||
}
|
||
|
||
.glass-surface {
|
||
box-shadow:
|
||
0 1px 3px rgba(97, 121, 146, 0.05),
|
||
0 12px 28px rgba(97, 121, 146, 0.08),
|
||
0 32px 64px rgba(178, 128, 88, 0.06),
|
||
inset 0 1px 0 rgba(255, 255, 255, 0.7),
|
||
inset 0 -1px 0 rgba(255, 255, 255, 0.12) !important;
|
||
}
|
||
|
||
.glass-floating-surface {
|
||
box-shadow:
|
||
0 2px 8px rgba(97, 121, 146, 0.08),
|
||
0 18px 40px rgba(97, 121, 146, 0.13),
|
||
0 56px 110px rgba(178, 128, 88, 0.11),
|
||
inset 0 1px 0 rgba(255, 255, 255, 0.8),
|
||
inset 0 -1px 0 rgba(255, 255, 255, 0.16) !important;
|
||
}
|
||
|
||
.search-shell {
|
||
isolation: isolate;
|
||
}
|
||
|
||
.search-suggest-portal {
|
||
isolation: isolate;
|
||
}
|
||
|
||
.search-suggest-panel {
|
||
overflow: auto;
|
||
transform-origin: top left;
|
||
animation: search-panel-enter 180ms cubic-bezier(0.22, 1, 0.36, 1);
|
||
}
|
||
|
||
.search-suggest-item {
|
||
min-height: 3.25rem;
|
||
}
|
||
|
||
@keyframes search-panel-enter {
|
||
from {
|
||
opacity: 0;
|
||
transform: translateY(-6px) scale(0.98);
|
||
}
|
||
|
||
to {
|
||
opacity: 1;
|
||
transform: translateY(0) scale(1);
|
||
}
|
||
}
|
||
|
||
.glass-nav-card::before,
|
||
.glass-nav-card::after,
|
||
.glass-surface::before,
|
||
.glass-surface::after {
|
||
content: '';
|
||
position: absolute;
|
||
inset: 0;
|
||
pointer-events: none;
|
||
border-radius: inherit;
|
||
}
|
||
|
||
.glass-nav-card::before,
|
||
.glass-surface::before {
|
||
background:
|
||
linear-gradient(135deg, rgba(255, 255, 255, 0.34), rgba(255, 255, 255, 0.08) 48%, rgba(151, 197, 224, 0.08)),
|
||
radial-gradient(circle at top left, rgba(255, 255, 255, 0.45), transparent 42%);
|
||
opacity: 0.95;
|
||
}
|
||
|
||
.glass-nav-card::after,
|
||
.glass-surface::after {
|
||
inset: 1px;
|
||
border: 1px solid rgba(255, 255, 255, 0.24);
|
||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.14), transparent 36%, rgba(255, 255, 255, 0.08));
|
||
}
|
||
|
||
.glass-nav-card:hover {
|
||
border-color: rgba(255, 255, 255, 0.74) !important;
|
||
box-shadow:
|
||
0 2px 6px rgba(97, 121, 146, 0.06),
|
||
0 20px 44px rgba(97, 121, 146, 0.13),
|
||
0 52px 100px rgba(178, 128, 88, 0.1),
|
||
inset 0 1px 0 rgba(255, 255, 255, 0.8),
|
||
inset 0 -1px 0 rgba(255, 255, 255, 0.18) !important;
|
||
}
|
||
|
||
.glass-nav-card--warm {
|
||
background:
|
||
linear-gradient(135deg, rgba(255, 249, 242, 0.4), rgba(255, 245, 231, 0.2)),
|
||
linear-gradient(180deg, rgba(255, 236, 211, 0.14), rgba(244, 225, 204, 0.06)) !important;
|
||
}
|
||
|
||
:deep(.glass-nav-card > .n-card__content) {
|
||
position: relative;
|
||
z-index: 1;
|
||
background: transparent !important;
|
||
}
|
||
|
||
.glass-surface > * {
|
||
position: relative;
|
||
z-index: 1;
|
||
}
|
||
|
||
:global(.search-select-menu) {
|
||
position: relative;
|
||
overflow: hidden;
|
||
padding: 6px;
|
||
border: 1px solid rgba(255, 255, 255, 0.58) !important;
|
||
border-radius: 22px !important;
|
||
background:
|
||
linear-gradient(135deg, rgba(255, 255, 255, 0.34), rgba(255, 255, 255, 0.16)),
|
||
linear-gradient(180deg, rgba(241, 248, 252, 0.2), rgba(224, 236, 245, 0.08)) !important;
|
||
box-shadow:
|
||
0 2px 8px rgba(97, 121, 146, 0.08),
|
||
0 18px 40px rgba(97, 121, 146, 0.13),
|
||
0 56px 110px rgba(178, 128, 88, 0.11),
|
||
inset 0 1px 0 rgba(255, 255, 255, 0.8),
|
||
inset 0 -1px 0 rgba(255, 255, 255, 0.16) !important;
|
||
backdrop-filter: blur(24px) saturate(165%);
|
||
-webkit-backdrop-filter: blur(24px) saturate(165%);
|
||
transform-origin: top left;
|
||
animation: search-panel-enter 180ms cubic-bezier(0.22, 1, 0.36, 1);
|
||
}
|
||
|
||
:global(.search-select-menu::before),
|
||
:global(.search-select-menu::after) {
|
||
content: '';
|
||
position: absolute;
|
||
inset: 0;
|
||
pointer-events: none;
|
||
border-radius: inherit;
|
||
}
|
||
|
||
:global(.search-select-menu::before) {
|
||
background:
|
||
linear-gradient(135deg, rgba(255, 255, 255, 0.34), rgba(255, 255, 255, 0.08) 48%, rgba(151, 197, 224, 0.08)),
|
||
radial-gradient(circle at top left, rgba(255, 255, 255, 0.45), transparent 42%);
|
||
opacity: 0.95;
|
||
}
|
||
|
||
:global(.search-select-menu::after) {
|
||
inset: 1px;
|
||
border: 1px solid rgba(255, 255, 255, 0.24);
|
||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.14), transparent 36%, rgba(255, 255, 255, 0.08));
|
||
}
|
||
|
||
:global(.search-select-menu .n-base-select-menu-option-wrapper),
|
||
:global(.search-select-menu .n-base-select-menu__empty) {
|
||
position: relative;
|
||
z-index: 1;
|
||
}
|
||
|
||
:global(.search-select-menu .n-base-select-menu-option-wrapper) {
|
||
padding: 0 !important;
|
||
}
|
||
|
||
:global(.search-select-menu .n-base-select-option) {
|
||
min-height: 2.45rem;
|
||
margin: 0;
|
||
padding-left: 0.7rem !important;
|
||
padding-right: 0.7rem !important;
|
||
border-radius: 16px;
|
||
color: #475569;
|
||
font-weight: 700;
|
||
letter-spacing: 0;
|
||
transition:
|
||
transform 0.2s ease,
|
||
background-color 0.2s ease,
|
||
color 0.2s ease,
|
||
box-shadow 0.2s ease,
|
||
border-color 0.2s ease;
|
||
}
|
||
|
||
:global(.search-select-menu .n-base-select-option:not(.n-base-select-option--selected).n-base-select-option--pending) {
|
||
transform: none;
|
||
background: rgba(255, 255, 255, 0.62);
|
||
box-shadow:
|
||
0 2px 6px rgba(120, 144, 168, 0.08),
|
||
inset 0 1px 0 rgba(255, 255, 255, 0.45);
|
||
}
|
||
|
||
:global(.search-select-menu .n-base-select-option--selected) {
|
||
background: linear-gradient(135deg, rgba(255, 240, 246, 0.98), rgba(255, 233, 223, 0.96));
|
||
color: #364152;
|
||
box-shadow:
|
||
0 3px 8px rgba(236, 102, 171, 0.1),
|
||
0 10px 18px rgba(236, 102, 171, 0.08),
|
||
inset 0 1px 0 rgba(255, 255, 255, 0.52);
|
||
}
|
||
|
||
:global(.search-select-menu .n-base-select-option--selected .n-base-select-option__content),
|
||
:global(.search-select-menu .n-base-select-option--selected .n-base-select-option__check) {
|
||
color: inherit;
|
||
}
|
||
|
||
:global(.search-select-menu .n-base-select-option__content) {
|
||
display: block;
|
||
position: relative;
|
||
z-index: 1;
|
||
}
|
||
|
||
:global(.search-select-menu .n-base-select-option__check) {
|
||
display: none !important;
|
||
}
|
||
|
||
:global(.search-select-menu .n-base-select-menu__empty) {
|
||
padding: 0.6rem 0.4rem;
|
||
color: #64748b;
|
||
}
|
||
|
||
:deep(.modal-input .n-input) {
|
||
background: #ffffff !important;
|
||
border: 1px solid rgba(198, 212, 226, 0.92) !important;
|
||
border-radius: 16px !important;
|
||
box-shadow:
|
||
inset 0 1px 0 rgba(255, 255, 255, 0.95),
|
||
0 2px 6px rgba(101, 126, 150, 0.05),
|
||
0 10px 22px rgba(101, 126, 150, 0.07);
|
||
}
|
||
|
||
:deep(.modal-input .n-input:hover) {
|
||
border-color: rgba(156, 183, 210, 0.98) !important;
|
||
}
|
||
|
||
:deep(.modal-input .n-input.n-input--focus) {
|
||
border-color: rgba(236, 102, 171, 0.65) !important;
|
||
box-shadow:
|
||
0 0 0 4px rgba(236, 102, 171, 0.12),
|
||
0 2px 6px rgba(101, 126, 150, 0.06),
|
||
0 12px 24px rgba(101, 126, 150, 0.09) !important;
|
||
}
|
||
|
||
:deep(.modal-input .n-input__input-el) {
|
||
color: #24364a !important;
|
||
}
|
||
|
||
:deep(.modal-input .n-input__placeholder) {
|
||
color: #93a2b3 !important;
|
||
}
|
||
|
||
:deep(.n-card > .n-card__content) {
|
||
height: 100%;
|
||
}
|
||
|
||
/* ===== 卡片入场 / 重排动画 ===== */
|
||
.nav-card-enter-active {
|
||
transition:
|
||
opacity 0.5s cubic-bezier(0.22, 1, 0.36, 1),
|
||
transform 0.5s cubic-bezier(0.22, 1, 0.36, 1);
|
||
transition-delay: calc(var(--i, 0) * 42ms);
|
||
}
|
||
|
||
.nav-card-enter-from {
|
||
opacity: 0;
|
||
transform: translateY(22px) scale(0.94);
|
||
}
|
||
|
||
/* wrap 容器无任何 transition —— Vue 检测不到 transform 过渡,
|
||
从而禁用其内置 move 逻辑(有删除时 onUpdated 同步执行会因占位计算错误),
|
||
重排动画由手动 FLIP(watch navlist + 双 rAF)接管 */
|
||
.nav-card-leave-active {
|
||
transition: none !important;
|
||
}
|
||
|
||
/* ===== 卡片 hover 增强 ===== */
|
||
.glass-nav-card:hover .grid-image {
|
||
transform: translateY(-3px) scale(1.08) rotate(-3deg);
|
||
box-shadow:
|
||
0 2px 6px rgba(97, 121, 146, 0.08),
|
||
0 14px 26px rgba(97, 121, 146, 0.14),
|
||
inset 0 1px 0 rgba(255, 255, 255, 0.95);
|
||
}
|
||
|
||
.card-name {
|
||
transition: color 0.25s ease;
|
||
}
|
||
|
||
.glass-nav-card:hover .card-name {
|
||
background: linear-gradient(135deg, #ec66ab, #f48a6f);
|
||
-webkit-background-clip: text;
|
||
background-clip: text;
|
||
color: transparent;
|
||
}
|
||
|
||
.glass-nav-card--warm:hover .add-card-plus {
|
||
transform: rotate(90deg) scale(1.08);
|
||
}
|
||
|
||
/* ===== 搜索框聚焦发光 ===== */
|
||
.search-shell:focus-within .glass-surface {
|
||
border-color: rgba(236, 102, 171, 0.42) !important;
|
||
box-shadow:
|
||
0 0 0 4px rgba(236, 102, 171, 0.1),
|
||
0 2px 6px rgba(97, 121, 146, 0.05),
|
||
0 16px 36px rgba(97, 121, 146, 0.12),
|
||
inset 0 1px 0 rgba(255, 255, 255, 0.7) !important;
|
||
}
|
||
|
||
/* ===== 快捷键徽章 ===== */
|
||
.search-kbd {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
padding: 0.2rem 0.6rem;
|
||
font-size: 11px;
|
||
font-weight: 700;
|
||
letter-spacing: 0.06em;
|
||
color: rgba(100, 116, 139, 0.85);
|
||
border: 1px solid rgba(255, 255, 255, 0.65);
|
||
border-radius: 9999px;
|
||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.24));
|
||
box-shadow:
|
||
0 2px 6px rgba(97, 121, 146, 0.08),
|
||
inset 0 1px 0 rgba(255, 255, 255, 0.7);
|
||
backdrop-filter: blur(12px);
|
||
}
|
||
|
||
.kbd {
|
||
display: inline-flex;
|
||
min-width: 1.05rem;
|
||
height: 1.05rem;
|
||
align-items: center;
|
||
justify-content: center;
|
||
margin: 0 1px;
|
||
padding: 0 0.3rem;
|
||
font-size: 10px;
|
||
font-weight: 700;
|
||
color: #64748b;
|
||
border: 1px solid rgba(148, 163, 184, 0.35);
|
||
border-radius: 0.3rem;
|
||
background: rgba(255, 255, 255, 0.65);
|
||
box-shadow: 0 1px 2px rgba(100, 116, 139, 0.1);
|
||
}
|
||
|
||
/* ===== 右键菜单 ===== */
|
||
.menu-pop {
|
||
min-width: 11.5rem;
|
||
overflow: hidden;
|
||
border: 1px solid rgba(255, 255, 255, 0.62);
|
||
background:
|
||
linear-gradient(135deg, rgba(255, 255, 255, 0.42), rgba(255, 255, 255, 0.24)),
|
||
linear-gradient(180deg, rgba(241, 248, 252, 0.26), rgba(224, 236, 245, 0.1)) !important;
|
||
backdrop-filter: blur(26px) saturate(170%);
|
||
-webkit-backdrop-filter: blur(26px) saturate(170%);
|
||
transform-origin: top left;
|
||
animation: menu-enter 160ms cubic-bezier(0.22, 1, 0.36, 1);
|
||
}
|
||
|
||
.menu-head {
|
||
padding: 0.65rem 1rem 0.6rem;
|
||
font-size: 12px;
|
||
font-weight: 700;
|
||
letter-spacing: 0.04em;
|
||
color: #475569;
|
||
border-bottom: 1px solid rgba(255, 255, 255, 0.55);
|
||
background: linear-gradient(135deg, rgba(255, 240, 246, 0.5), rgba(255, 233, 223, 0.34));
|
||
}
|
||
|
||
.menu-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.55rem;
|
||
padding: 0.62rem 1rem;
|
||
font-size: 13.5px;
|
||
font-weight: 600;
|
||
color: #475569;
|
||
transition:
|
||
background-color 0.18s ease,
|
||
color 0.18s ease,
|
||
padding-left 0.18s ease;
|
||
}
|
||
|
||
.menu-item:hover {
|
||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.75), rgba(255, 255, 255, 0.45));
|
||
color: #ec66ab;
|
||
padding-left: 1.2rem;
|
||
}
|
||
|
||
.menu-item--danger {
|
||
color: #e05d5d;
|
||
}
|
||
|
||
.menu-item--danger:hover {
|
||
background: rgba(255, 236, 236, 0.85);
|
||
color: #d94040;
|
||
}
|
||
|
||
.menu-icon {
|
||
width: 1.05rem;
|
||
height: 1.05rem;
|
||
flex-shrink: 0;
|
||
opacity: 0.85;
|
||
}
|
||
|
||
.menu-divider {
|
||
height: 1px;
|
||
margin: 0.3rem 0.9rem;
|
||
background: linear-gradient(90deg, rgba(148, 163, 184, 0.28), rgba(148, 163, 184, 0.08));
|
||
}
|
||
|
||
@keyframes menu-enter {
|
||
from {
|
||
opacity: 0;
|
||
transform: translateY(-6px) scale(0.96);
|
||
}
|
||
|
||
to {
|
||
opacity: 1;
|
||
transform: translateY(0) scale(1);
|
||
}
|
||
}
|
||
|
||
/* ===== 标签交互增强 ===== */
|
||
.nav-tag {
|
||
transition:
|
||
transform 0.18s cubic-bezier(0.22, 1, 0.36, 1),
|
||
background-color 0.2s ease,
|
||
box-shadow 0.2s ease,
|
||
color 0.2s ease;
|
||
}
|
||
|
||
.nav-tag:hover {
|
||
transform: translateY(-1.5px);
|
||
}
|
||
|
||
.nav-tag:active {
|
||
transform: scale(0.92);
|
||
}
|
||
|
||
/* ===== 常用标签快捷选择 ===== */
|
||
.quick-tag {
|
||
padding: 0.28rem 0.75rem;
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
color: #64748b;
|
||
border: 1px solid rgba(203, 213, 225, 0.9);
|
||
border-radius: 9999px;
|
||
background: rgba(255, 255, 255, 0.72);
|
||
cursor: pointer;
|
||
transition:
|
||
transform 0.18s ease,
|
||
border-color 0.18s ease,
|
||
color 0.18s ease,
|
||
background-color 0.18s ease,
|
||
box-shadow 0.18s ease;
|
||
}
|
||
|
||
.quick-tag:hover {
|
||
transform: translateY(-1px);
|
||
border-color: rgba(236, 102, 171, 0.55);
|
||
color: #ec66ab;
|
||
box-shadow:
|
||
0 2px 6px rgba(236, 102, 171, 0.1),
|
||
0 6px 14px rgba(236, 102, 171, 0.08);
|
||
}
|
||
|
||
.quick-tag--active {
|
||
color: #fff;
|
||
border-color: transparent;
|
||
background: linear-gradient(135deg, #ec66ab, #f48a6f);
|
||
box-shadow:
|
||
0 3px 8px rgba(236, 102, 171, 0.2),
|
||
0 10px 18px rgba(236, 102, 171, 0.14);
|
||
}
|
||
|
||
/* ===== 置顶星标 ===== */
|
||
.nav-pin {
|
||
animation: pin-in 0.4s cubic-bezier(0.22, 1, 0.36, 1);
|
||
}
|
||
|
||
@keyframes pin-in {
|
||
from {
|
||
opacity: 0;
|
||
transform: scale(0) rotate(-90deg);
|
||
}
|
||
|
||
to {
|
||
opacity: 1;
|
||
transform: scale(1) rotate(0);
|
||
}
|
||
}
|
||
|
||
:deep(.nav-tag) {
|
||
margin: 0 !important;
|
||
box-sizing: border-box;
|
||
border: none !important;
|
||
outline: none !important;
|
||
border-radius: 9999px !important;
|
||
overflow: hidden;
|
||
}
|
||
|
||
:deep(.nav-tag .beyond-tag__text) {
|
||
font-weight: 600;
|
||
}
|
||
|
||
:deep(.nav-tag.devui-tag-item) {
|
||
background: transparent !important;
|
||
border: none !important;
|
||
box-shadow: none !important;
|
||
}
|
||
|
||
:deep(.nav-tag.devui-tag-item.active) {
|
||
background: transparent !important;
|
||
color: inherit !important;
|
||
border: none !important;
|
||
}
|
||
|
||
:deep(.nav-tag .devui-tag) {
|
||
border: none !important;
|
||
border-radius: 9999px !important;
|
||
background: transparent !important;
|
||
box-shadow: none !important;
|
||
}
|
||
|
||
:deep(.nav-tag .devui-tag__item) {
|
||
border: none !important;
|
||
border-radius: 9999px !important;
|
||
background: transparent !important;
|
||
}
|
||
|
||
:deep(.nav-tag:hover),
|
||
:deep(.nav-tag:focus),
|
||
:deep(.nav-tag:active) {
|
||
border: none !important;
|
||
outline: none !important;
|
||
box-shadow: none !important;
|
||
}
|
||
|
||
:deep(.navcard .n-card > .n-card__content) {
|
||
padding: 0 !important;
|
||
display: flex;
|
||
justify-content: center;
|
||
align-items: center;
|
||
}
|
||
</style>
|
||
|