Files
blog/src/views/index.vue
2026-08-04 17:33:45 +08:00

1721 lines
52 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="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>&nbsp;选择</span>
<span><b class="kbd"></b>&nbsp;打开</span>
<span><b class="kbd">Esc</b>&nbsp;关闭</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-flushDOM 更新前记录旧位置
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 同步执行会因占位计算错误),
重排动画由手动 FLIPwatch 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>