feat(navbar): enhance NavbarActions and TagsView with improved hover effects and quick link functionality

- Updated NavbarActions.vue to refine hover styles for icons and buttons, ensuring better visual feedback.
- Enhanced TagsView.vue by integrating a bookmark button for quick links, allowing users to easily add or remove favorites.
- Introduced a mechanism to synchronize quick link changes with local storage for immediate UI updates.
- Improved overall styling for better user experience and accessibility.
This commit is contained in:
zhangtao
2026-03-28 02:59:08 +08:00
parent 015e403060
commit c304fa92d8
9 changed files with 1921 additions and 356 deletions
@@ -314,12 +314,31 @@ function logout() {
transition: color 0.3s;
}
// Element Plus 图标(如 el-icon)样式
:deep(.el-icon) {
color: var(--el-text-color-regular);
transition: color 0.3s;
}
&:hover {
background: var(--el-fill-color-light);
background: transparent;
:deep([class^="i-svg:"]) {
color: var(--el-color-primary);
}
:deep(.el-icon) {
color: var(--el-color-primary);
}
// 搜索触发器:确保 hover 时是图标变色,而不是只出现边框变化
:deep(.command-palette-trigger) {
border-color: var(--el-border-color-lighter) !important;
}
:deep(.command-palette-trigger__left [class^="i-svg:"]) {
color: var(--el-color-primary) !important;
}
}
}
@@ -335,12 +354,18 @@ function logout() {
display: flex;
align-items: center;
justify-content: center;
// 给实际渲染的 el-avatar 增加边框
:deep(.el-avatar) {
border: 1px solid var(--el-color-primary);
}
}
&__avatar {
flex-shrink: 0;
width: 28px;
height: 28px;
border: 1px solid var(--el-color-success);
border-radius: 50%;
}
@@ -350,7 +375,7 @@ function logout() {
bottom: 0;
width: 8px;
height: 8px;
background-color: #50b731;
background-color: var(--el-color-success);
border-radius: 50%;
box-shadow: 0 0 2px rgba(0, 0, 0, 0.2);
}
@@ -371,11 +396,27 @@ function logout() {
color: color-mix(in srgb, var(--el-color-white) 85%, transparent);
}
:deep(.el-icon) {
color: color-mix(in srgb, var(--el-color-white) 85%, transparent);
}
&:hover {
background: color-mix(in srgb, var(--el-color-white) 10%, transparent);
background: transparent;
:deep([class^="i-svg:"]) {
color: var(--el-color-white);
color: var(--el-color-primary);
}
:deep(.el-icon) {
color: var(--el-color-primary);
}
:deep(.command-palette-trigger) {
border-color: color-mix(in srgb, var(--el-color-white) 35%, transparent) !important;
}
:deep(.command-palette-trigger__left [class^="i-svg:"]) {
color: var(--el-color-primary) !important;
}
}
}
@@ -393,11 +434,19 @@ function logout() {
}
&:hover {
background: rgba(0, 0, 0, 0.04);
background: transparent;
:deep([class^="i-svg:"]) {
color: var(--el-color-primary) !important;
}
:deep(.command-palette-trigger) {
border-color: var(--el-border-color-lighter) !important;
}
:deep(.command-palette-trigger__left [class^="i-svg:"]) {
color: var(--el-color-primary) !important;
}
}
}
@@ -23,7 +23,22 @@
@visible-change="(visible) => onContextMenuVisibleChange(visible, tag)"
@click.stop
>
<span class="tag-text">{{ translateRouteTitle(tag.title) }}</span>
<span class="tag-main">
<button
type="button"
class="tag-bookmark-btn"
:class="{ 'is-bookmarked': isQuickLinkExists(tag) }"
:title="isQuickLinkExists(tag) ? '取消收藏' : '加入收藏'"
:aria-label="isQuickLinkExists(tag) ? '取消收藏' : '加入收藏'"
@click.prevent.stop="toggleQuickStart(tag)"
>
<el-icon :size="14">
<StarFilled v-if="isQuickLinkExists(tag)" />
<Star v-else />
</el-icon>
</button>
<span class="tag-text">{{ translateRouteTitle(tag.title) }}</span>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="refreshSelectedTag(tag)">
@@ -73,15 +88,6 @@
</el-icon>
{{ t("navbar.closeAll") }}
</el-dropdown-item>
<!-- 收藏到快速开始 -->
<el-dropdown-item divided @click="toggleQuickStart(tag)">
<el-icon>
<Star v-if="!isQuickLinkExists(tag)" />
<StarFilled v-else />
</el-icon>
{{ isQuickLinkExists(tag) ? "取消收藏" : "收藏" }}
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
@@ -207,6 +213,13 @@ const scrollbarRef = ref();
// 标签切换来源跟踪
const tagSwitchSource = ref<"menu" | "tab" | null>(null);
/** 收藏列表变更时递增,使星标与 localStorage 同步(否则取消收藏后 UI 不更新) */
const quickLinksRevision = ref(0);
const onQuickLinksChanged = () => {
quickLinksRevision.value++;
};
// 路由映射缓存,提升查找性能
const routePathMap = new Map<string, TagView>();
@@ -577,18 +590,19 @@ const toggleQuickStart = (tag: TagView) => {
const isExists = quickStartManager.isLinkExists(href);
if (isExists) {
// 取消收藏:找到对应的链接并删除
const links = quickStartManager.getQuickLinks();
const targetLink = links.find((link) => link.href === href);
if (targetLink?.id) {
quickStartManager.removeQuickLink(targetLink.id);
ElMessage.success(`已取消收藏:${tag.title}`);
} else if (href) {
quickStartManager.removeQuickLinkByHref(href);
}
ElMessage.success(`已取消收藏:${tag.title}`);
} else {
// 添加收藏
const quickLink = quickStartManager.createQuickLinkFromRoute(tag);
quickStartManager.addQuickLink(quickLink);
ElMessage.success(`已收藏:${tag.title}`);
if (quickStartManager.addQuickLink(quickLink)) {
ElMessage.success(`已收藏:${tag.title}`);
}
}
} catch (error) {
console.error("Failed to toggle quick start:", error);
@@ -597,9 +611,10 @@ const toggleQuickStart = (tag: TagView) => {
};
/**
* 检查快速链接是否已存在
* 检查快速链接是否已存在(依赖 quickLinksRevision,以便收藏增删后星标立即刷新)
*/
const isQuickLinkExists = (tag: TagView): boolean => {
void quickLinksRevision.value;
return quickStartManager.isLinkExists(tag.fullPath || tag.path);
};
@@ -755,6 +770,8 @@ watch(
onMounted(() => {
initAffixTags();
quickStartManager.addListener(onQuickLinksChanged);
// 初始化路由映射缓存
updateRoutePathMap();
@@ -773,6 +790,7 @@ onMounted(() => {
// 清理
onUnmounted(() => {
quickStartManager.removeListener(onQuickLinksChanged);
if (resizeObserver) {
resizeObserver.disconnect();
}
@@ -845,10 +863,47 @@ onUnmounted(() => {
:deep(.el-dropdown) {
display: inline-flex;
align-items: center;
max-width: 100%;
line-height: 1;
}
.tag-main {
display: inline-flex;
gap: 4px;
align-items: center;
min-width: 0;
}
.tag-bookmark-btn {
display: inline-flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
padding: 0;
margin: 0;
line-height: 1;
color: var(--el-text-color-placeholder);
cursor: pointer;
outline: none;
background: transparent;
border: none;
border-radius: 4px;
&:hover {
color: var(--el-color-primary);
}
&:focus-visible {
box-shadow: 0 0 0 2px var(--el-color-primary-light-7);
}
&.is-bookmarked {
color: var(--el-color-primary);
}
}
.tag-text {
min-width: 0;
line-height: 1.25;
}
@@ -881,6 +936,14 @@ onUnmounted(() => {
color: var(--el-color-primary);
}
.tag-bookmark-btn:not(.is-bookmarked) {
color: var(--el-text-color-secondary);
&:hover {
color: var(--el-color-primary);
}
}
.tag-close-btn {
color: var(--el-color-primary);
&:hover {
-2
View File
@@ -205,8 +205,6 @@ html .el-table {
}
html.dark .el-table {
--el-table-header-bg-color: var(--el-fill-color);
/* --el-table-striped-row-bg: var(--el-fill-color); */
--el-table-row-hover-bg-color: var(--el-fill-color-light);
}
+36
View File
@@ -75,10 +75,46 @@
background-color: var(--el-color-primary-light-9) !important;
}
// 经典蓝:激活项背景不使用偏白色,改为蓝系轻色差背景
html.sidebar-color-blue {
.el-menu--vertical .el-menu-item.is-active,
.el-menu--popup .el-menu-item.is-active {
background-color: var(--menu-hover) !important;
}
}
// 混合布局左侧菜单 hover 不单独覆盖:
// 统一交给 Element Plus 的 --el-menu-hover-bg-color 与 MenuItem.vue 的通用规则,
// 确保 Mix-left 与 Left 侧边布局保持一致。
// 暗色:菜单悬浮背景使用主题浅色,避免黑色悬浮
html.dark .el-menu {
.el-menu-item:hover,
.el-sub-menu__title:hover {
background-color: var(--el-color-primary-light-9) !important;
}
}
html.dark .el-menu--popup {
.el-menu-item:hover {
background-color: var(--el-color-primary-light-9) !important;
}
}
// 亮色 + 经典蓝:悬浮背景使用经典蓝体系(比底色略深),避免发白
html.sidebar-color-blue .el-menu {
.el-menu-item:hover,
.el-sub-menu__title:hover {
background-color: var(--menu-hover) !important;
}
}
html.sidebar-color-blue .el-menu--popup {
.el-menu-item:hover {
background-color: var(--menu-hover) !important;
}
}
// ==================== 窄屏时隐藏菜单文字,只显示图标 ====================
// 窄屏时隐藏菜单文字,只显示图标
.hideSidebar {
+26 -7
View File
@@ -1,5 +1,8 @@
import { ElMessage } from "element-plus";
/** 收藏数量上限(工作台固定两列展示,不再使用内部滚动区) */
export const QUICK_LINK_MAX = 10;
// 快速链接数据类型
export interface QuickLink {
title: string;
@@ -39,22 +42,29 @@ class QuickStartManager {
}
}
// 添加快速链接
addQuickLink(link: QuickLink): void {
/**
* 添加或更新快速链接。
* @returns 是否已保存;新增时若已达上限则提示并返回 false
*/
addQuickLink(link: QuickLink): boolean {
const links = this.getQuickLinks();
// 检查是否已存在相同路径的链接
const existingIndex = links.findIndex((l) => l.href === link.href);
if (existingIndex !== -1) {
// 更新现有链接
links[existingIndex] = { ...links[existingIndex], ...link };
ElMessage.success(`已更新快速链接:${link.title}`);
} else {
// 添加新链接
links.push(link);
this.saveQuickLinks(links);
return true;
}
if (links.length >= QUICK_LINK_MAX) {
ElMessage.warning(`收藏已满(最多 ${QUICK_LINK_MAX} 个),请先移除后再添加`);
return false;
}
links.push(link);
this.saveQuickLinks(links);
return true;
}
// 删除快速链接
@@ -67,6 +77,15 @@ class QuickStartManager {
}
}
/** 无 id 的旧数据可按路由路径移除 */
removeQuickLinkByHref(href: string): void {
const links = this.getQuickLinks();
const filteredLinks = links.filter((link) => link.href !== href);
if (filteredLinks.length < links.length) {
this.saveQuickLinks(filteredLinks);
}
}
// 清空所有快速链接
clearQuickLinks(): void {
this.saveQuickLinks([]);
@@ -0,0 +1,120 @@
import type { MenuTable } from "@/api/module_system/menu";
/**
* 工作台「模块总览」与 src/views 下目录对应:
* module_system / module_monitor / module_task / module_ai / module_application / module_generator / module_example / module_common
*/
export interface MenuLeaf {
title: string;
path: string;
icon?: string;
component_path?: string;
order?: number;
}
export interface WorkplaceModuleGroup {
key: string;
title: string;
subtitle: string;
/** 与菜单 component_path 片段匹配(含 views 相对路径) */
matchHints: string[];
}
/** 按「业务域」划分,与前端 views 目录一致 */
export const WORKPLACE_MODULE_GROUPS: WorkplaceModuleGroup[] = [
{
key: "system",
title: "系统管理",
subtitle: "用户、角色、菜单、部门、字典、岗位、参数、公告、日志等",
matchHints: ["module_system/"],
},
{
key: "monitor",
title: "监控运维",
subtitle: "服务监控、缓存、在线用户、资源占用等",
matchHints: ["module_monitor/"],
},
{
key: "task",
title: "任务与流程",
subtitle: "定时任务、执行节点、工作流等",
matchHints: ["module_task/"],
},
{
key: "ai",
title: "AI 助手",
subtitle: "对话、记忆管理等",
matchHints: ["module_ai/"],
},
{
key: "app",
title: "应用与生成",
subtitle: "应用管理、代码生成、示例演示等",
matchHints: ["module_application/", "module_generator/", "module_example/"],
},
{
key: "docs",
title: "文档中心",
subtitle: "Swagger / Redoc / 本地文档等",
matchHints: ["module_common/"],
},
];
function normalizePath(p: string) {
return p.replace(/\\/g, "/");
}
/**
* 在已授权的叶子菜单中,按 matchHints 顺序找到第一个可进入的路由
*/
export function resolveEntryPath(
leaves: Pick<MenuLeaf, "path" | "component_path">[],
hints: string[]
): string | undefined {
for (const hint of hints) {
const hit = leaves.find(
(l) => l.component_path && normalizePath(l.component_path).includes(hint)
);
if (hit) return hit.path;
}
return undefined;
}
export function resolveEntryTitle(leaves: MenuLeaf[], hints: string[]): string | undefined {
for (const hint of hints) {
const hit = leaves.find(
(l) => l.component_path && normalizePath(l.component_path).includes(hint)
);
if (hit) return hit.title;
}
return undefined;
}
/** 叶子菜单扁平化(与侧栏同源),按菜单 order 排序 */
export function flattenLeafMenusFromTree(menus: MenuTable[]): MenuLeaf[] {
const seen = new Set<string>();
const out: MenuLeaf[] = [];
const walk = (items: MenuTable[]) => {
for (const m of items) {
if (m.hidden) continue;
if (m.children?.length) {
walk(m.children);
} else if (m.title && m.route_path) {
const raw = m.route_path.trim();
const path = raw.startsWith("/") ? raw : `/${raw}`;
if (seen.has(path)) continue;
seen.add(path);
out.push({
title: m.title,
path,
icon: m.icon,
component_path: m.component_path,
order: m.order,
});
}
}
};
walk(menus);
out.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
return out;
}
File diff suppressed because it is too large Load Diff
@@ -1,17 +1,32 @@
<!-- 部门树 -->
<template>
<el-card shadow="hover">
<el-input v-model="deptName" placeholder="部门名称">
<template #prefix>
<el-icon>
<Search />
<div class="dept-tree-toolbar">
<el-input v-model="deptName" placeholder="部门名称" class="dept-tree-search">
<template #prefix>
<el-icon>
<Search />
</el-icon>
</template>
</el-input>
<el-button
type="primary"
link
size="small"
class="dept-tree-expand-btn"
@click="toggleTreeExpandAll"
>
<el-icon :size="14">
<Switch />
</el-icon>
</template>
</el-input>
<span>{{ treeExpanded ? "收起" : "展开" }}</span>
</el-button>
</div>
<el-tree
ref="deptTreeRef"
class="mt-2"
node-key="value"
:data="deptOptions"
:props="{ children: 'children', label: 'label', disabled: 'disabled' }"
:expand-on-click-node="false"
@@ -27,6 +42,7 @@
</template>
<script setup lang="ts">
import { Switch } from "@element-plus/icons-vue";
import DeptAPI, { DeptPageQuery } from "@/api/module_system/dept";
import { formatTree } from "@/utils/common";
import type { FilterNodeMethodFunction, TreeInstance } from "element-plus";
@@ -41,15 +57,66 @@ const props = defineProps({
const deptOptions = ref<OptionType[]>([]); // 部门列表
const deptTreeRef = ref<TreeInstance>(); // 部门树
const deptName = ref(); // 部门名称
/** 与「全部展开」状态同步,用于按钮提示(手动展开单节点后可能不完全一致) */
const treeExpanded = ref(true);
const emits = defineEmits(["node-click", "update:modelValue"]);
const deptId = useVModel(props, "modelValue", emits);
watch(deptName, (val) => {
deptTreeRef.value!.filter(val);
deptTreeRef.value?.filter(val);
});
type TreeStoreNode = {
childNodes?: TreeStoreNode[];
expand: () => void;
collapse: () => void;
};
function getTreeRoot() {
const tree = deptTreeRef.value as TreeInstance & { store?: { root: TreeStoreNode } };
return tree?.store?.root;
}
/** 展开整棵树 */
function expandAllTreeNodes() {
const root = getTreeRoot();
if (!root) return;
const walk = (node: TreeStoreNode) => {
node.childNodes?.forEach((child) => {
if (child.childNodes?.length) {
child.expand();
walk(child);
}
});
};
walk(root);
}
/** 收起整棵树 */
function collapseAllTreeNodes() {
const root = getTreeRoot();
if (!root) return;
const walk = (node: TreeStoreNode) => {
node.childNodes?.forEach((child) => {
walk(child);
child.collapse();
});
};
walk(root);
}
function toggleTreeExpandAll() {
if (treeExpanded.value) {
collapseAllTreeNodes();
treeExpanded.value = false;
} else {
expandAllTreeNodes();
treeExpanded.value = true;
}
}
interface Tree {
[key: string]: any;
}
@@ -87,3 +154,24 @@ onBeforeMount(() => {
});
});
</script>
<style scoped lang="scss">
.dept-tree-toolbar {
display: flex;
gap: 4px;
align-items: center;
.dept-tree-search {
flex: 1;
min-width: 0;
}
.dept-tree-expand-btn {
flex-shrink: 0;
height: auto;
min-height: unset;
padding: 0 4px;
font-size: 12px;
}
}
</style>
@@ -308,6 +308,7 @@
trigger="click"
:persistent="false"
placement="auto-end"
popper-class="node-cron-popover-fix"
>
<template #reference>
<el-input
@@ -832,3 +833,33 @@ onMounted(async () => {
margin-top: 16px;
}
</style>
<!-- popover 挂载到 body,需单独写;修复 vue3-cron-plus 全局 .el-tag--info { margin-left: -60px } 误伤多选下拉里 tag -->
<style lang="scss">
.node-cron-popover-fix {
.vue3-cron-plus-container .el-select .el-tag {
margin-left: 0 !important;
}
/* 具体秒数等多选行:避免文案与选择器挤在同一行错位 */
.vue3-cron-plus-container .tabBody .el-radio.long {
align-items: flex-start;
height: auto;
white-space: normal;
.el-radio__label {
display: flex;
flex-wrap: wrap;
gap: 6px 8px;
align-items: center;
line-height: 1.5;
}
.el-select {
flex: 1 1 200px;
min-width: 180px;
max-width: 100%;
}
}
}
</style>