refactor: 界面重构美化

This commit is contained in:
Ray.Hao
2026-05-11 11:54:22 +08:00
parent 66615279eb
commit fe0775fbdb
31 changed files with 2836 additions and 2172 deletions

View File

@@ -1,67 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 1400 800">
<defs>
<!-- 原背景色保持不变 -->
<linearGradient id="bgDark" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#0b1324" />
<stop offset="58%" stop-color="#162135" />
<stop offset="100%" stop-color="#1e2c44" />
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#0a0f1a" />
<stop offset="50%" stop-color="#0d1424" />
<stop offset="100%" stop-color="#111a2e" />
</linearGradient>
<radialGradient id="glowPrimary" cx="18%" cy="14%" r="62%">
<stop offset="0%" stop-color="rgba(98,142,255,0.42)" />
<stop offset="46%" stop-color="rgba(98,142,255,0.18)" />
<stop offset="100%" stop-color="rgba(98,142,255,0)" />
<radialGradient id="glow1" cx="15%" cy="18%" r="55%">
<stop offset="0%" stop-color="rgba(22,119,255,0.15)" />
<stop offset="45%" stop-color="rgba(22,119,255,0.05)" />
<stop offset="100%" stop-color="rgba(22,119,255,0)" />
</radialGradient>
<radialGradient id="glowSecondary" cx="82%" cy="70%" r="56%">
<stop offset="0%" stop-color="rgba(59,108,224,0.28)" />
<stop offset="48%" stop-color="rgba(59,108,224,0.14)" />
<stop offset="100%" stop-color="rgba(59,108,224,0)" />
<radialGradient id="glow2" cx="85%" cy="72%" r="50%">
<stop offset="0%" stop-color="rgba(99,102,241,0.1)" />
<stop offset="45%" stop-color="rgba(99,102,241,0.03)" />
<stop offset="100%" stop-color="rgba(99,102,241,0)" />
</radialGradient>
<linearGradient id="meshDark" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="rgba(255,255,255,0.1)" />
<stop offset="35%" stop-color="rgba(255,255,255,0.04)" />
<stop offset="100%" stop-color="rgba(255,255,255,0)" />
</linearGradient>
</defs>
<!-- 基础背景 -->
<rect width="100%" height="100%" fill="url(#bgDark)" />
<rect width="100%" height="100%" fill="url(#glowPrimary)" />
<rect width="100%" height="100%" fill="url(#glowSecondary)" />
<rect width="100%" height="100%" fill="url(#meshDark)" />
<g opacity="0.8">
<circle cx="280" cy="190" r="120" fill="rgba(98,142,255,0.03)" />
<circle cx="280" cy="190" r="120" fill="none" stroke="rgba(98,142,255,0.11)" stroke-width="1" />
<circle cx="280" cy="190" r="170" fill="none" stroke="rgba(98,142,255,0.06)" stroke-width="0.8" />
<g transform="rotate(-12, 1120, 600)">
<path d="M900 620 C 980 520, 1120 520, 1240 580 C 1320 620, 1340 680, 1290 720 C 1200 790, 980 760, 900 690 Z" fill="rgba(98,142,255,0.018)" />
<path d="M900 620 C 980 520, 1120 520, 1240 580 C 1320 620, 1340 680, 1290 720 C 1200 790, 980 760, 900 690 Z" fill="none" stroke="rgba(98,142,255,0.08)" stroke-width="1" />
</g>
</g>
<!-- 节点圆点 -->
<g fill="rgba(98,142,255,0.5)">
<circle cx="300" cy="150" r="3" />
<circle cx="760" cy="220" r="3" />
<circle cx="1180" cy="560" r="3" />
</g>
<!-- 节点光晕 -->
<g fill="rgba(98,142,255,0.08)">
<circle cx="300" cy="150" r="10" />
<circle cx="760" cy="220" r="12" />
<circle cx="1180" cy="560" r="10" />
</g>
<!-- 右上角装饰圆弧 -->
<g fill="none" stroke="rgba(255,255,255,0.05)" stroke-width="0.5">
<circle cx="1400" cy="0" r="400" />
<circle cx="1400" cy="0" r="650" />
</g>
</svg>
<rect width="100%" height="100%" fill="url(#bg)" />
<rect width="100%" height="100%" fill="url(#glow1)" />
<rect width="100%" height="100%" fill="url(#glow2)" />
</svg>

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -1,42 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 1400 800">
<defs>
<linearGradient id="bgLight" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#f3f7ff" />
<stop offset="60%" stop-color="#e3edff" />
<stop offset="100%" stop-color="#d6e7ff" />
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#f0f4ff" />
<stop offset="50%" stop-color="#f5f8ff" />
<stop offset="100%" stop-color="#edf2fe" />
</linearGradient>
<radialGradient id="glowLight" cx="20%" cy="15%" r="60%">
<stop offset="0%" stop-color="rgba(64,128,255,0.35)" />
<stop offset="40%" stop-color="rgba(64,128,255,0.18)" />
<stop offset="100%" stop-color="rgba(64,128,255,0)" />
<radialGradient id="glow1" cx="15%" cy="20%" r="55%">
<stop offset="0%" stop-color="rgba(22,119,255,0.12)" />
<stop offset="50%" stop-color="rgba(22,119,255,0.04)" />
<stop offset="100%" stop-color="rgba(22,119,255,0)" />
</radialGradient>
<radialGradient id="glowSecondary" cx="80%" cy="70%" r="55%">
<stop offset="0%" stop-color="rgba(22,93,255,0.3)" />
<stop offset="50%" stop-color="rgba(22,93,255,0.12)" />
<stop offset="100%" stop-color="rgba(22,93,255,0)" />
<radialGradient id="glow2" cx="85%" cy="75%" r="50%">
<stop offset="0%" stop-color="rgba(99,102,241,0.08)" />
<stop offset="50%" stop-color="rgba(99,102,241,0.02)" />
<stop offset="100%" stop-color="rgba(99,102,241,0)" />
</radialGradient>
<linearGradient id="meshLight" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="rgba(255,255,255,0.6)" />
<stop offset="35%" stop-color="rgba(255,255,255,0.2)" />
<stop offset="100%" stop-color="rgba(255,255,255,0)" />
</linearGradient>
</defs>
<rect id="bg-layer" width="100%" height="100%" fill="url(#bgLight)" />
<rect id="soft-glow" width="100%" height="100%" fill="url(#glowLight)" />
<rect width="100%" height="100%" fill="url(#glowSecondary)" />
<rect width="100%" height="100%" fill="url(#meshLight)" />
<!-- 柔和块面光影,替代明显线条 -->
<g opacity="0.45">
<rect x="-40" y="520" width="520" height="220" rx="180" fill="rgba(255,255,255,0.25)" />
<rect x="760" y="90" width="520" height="210" rx="180" fill="rgba(255,255,255,0.22)" />
<rect x="420" y="620" width="560" height="190" rx="180" fill="rgba(255,255,255,0.18)" />
</g>
<!-- 去掉点状噪声,仅保留大区域柔光 -->
</svg>
<rect width="100%" height="100%" fill="url(#bg)" />
<rect width="100%" height="100%" fill="url(#glow1)" />
<rect width="100%" height="100%" fill="url(#glow2)" />
</svg>

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -201,7 +201,7 @@ onMounted(() => {
}
.icon-grid-item:hover {
border-color: #4080ff;
border-color: var(--el-color-primary);
transform: scale(1.2);
}
</style>

View File

@@ -1,7 +1,11 @@
<template>
<BaseLayout>
<!-- 左侧菜单 -->
<div class="layout__sidebar" :class="{ 'layout__sidebar--collapsed': !isSidebarOpen }">
<div
v-show="!appStore.contentFullscreen"
class="layout__sidebar"
:class="{ 'layout__sidebar--collapsed': !isSidebarOpen }"
>
<div :class="{ 'has-logo': showLogo }" class="layout-sidebar">
<LayoutLogo v-if="showLogo" :collapse="!isSidebarOpen" />
<el-scrollbar>
@@ -16,9 +20,10 @@
:class="{
hasTagsView: showTagsView,
'layout__main--collapsed': !isSidebarOpen,
'layout__main--fullscreen': appStore.contentFullscreen,
}"
>
<LayoutNavbar />
<LayoutNavbar v-show="!appStore.contentFullscreen" />
<LayoutTagsView v-if="showTagsView" />
<LayoutMain />
</div>
@@ -27,6 +32,7 @@
<script setup lang="ts">
import { useLayout } from "./useLayout";
import { useAppStore } from "@/stores";
import BaseLayout from "./BaseLayout.vue";
import LayoutLogo from "./components/LayoutLogo.vue";
import LayoutNavbar from "./components/LayoutNavbar.vue";
@@ -35,6 +41,7 @@ import LayoutMain from "./components/LayoutMain.vue";
import LayoutSidebar from "./components/LayoutSidebar.vue";
const { showTagsView, showLogo, isSidebarOpen, routes } = useLayout();
const appStore = useAppStore();
</script>
<style lang="scss" scoped>
@@ -57,6 +64,7 @@ const { showTagsView, showLogo, isSidebarOpen, routes } = useLayout();
position: relative;
height: 100%;
background-color: var(--menu-background);
border-right: 1px solid var(--card-border);
transition: width 0.28s;
&.has-logo {
@@ -82,6 +90,10 @@ const { showTagsView, showLogo, isSidebarOpen, routes } = useLayout();
margin-left: $sidebar-width-collapsed;
}
&--fullscreen {
margin-left: 0 !important;
}
.fixed-header {
position: sticky;
top: 0;

View File

@@ -1,7 +1,7 @@
<template>
<BaseLayout>
<!-- 顶部菜单栏 -->
<div class="layout__header">
<div v-show="!appStore.contentFullscreen" class="layout__header">
<div class="layout__header-content">
<div v-if="showLogo" class="layout__header-logo">
<LayoutLogo :collapse="isLogoCollapsed" />
@@ -37,7 +37,11 @@
<!-- 主内容区容器 -->
<div class="layout__container">
<!-- 左侧菜单栏 -->
<div class="layout__sidebar--left" :class="{ 'layout__sidebar--collapsed': !isSidebarOpen }">
<div
v-show="!appStore.contentFullscreen"
class="layout__sidebar--left"
:class="{ 'layout__sidebar--collapsed': !isSidebarOpen }"
>
<el-scrollbar>
<el-menu
:default-active="activeSideMenuPath"
@@ -233,7 +237,8 @@ watch(
width: 100%;
height: $navbar-height;
background-color: var(--menu-background);
border-bottom: 1px solid var(--el-border-color-lighter);
border-bottom: 1px solid var(--card-border);
box-shadow: 0 1px 3px 0 rgb(0 0 0 / 6%);
&-content {
display: flex;
@@ -301,6 +306,7 @@ watch(
width: $sidebar-width;
height: 100%;
background-color: var(--menu-background);
border-right: 1px solid var(--card-border);
transition: width 0.28s;
&.layout__sidebar--collapsed {

View File

@@ -1,7 +1,7 @@
<template>
<BaseLayout>
<!-- 顶部菜单-->
<div class="layout__header">
<div v-show="!appStore.contentFullscreen" class="layout__header">
<div class="layout__header-left">
<div v-if="showLogo" class="layout__header-logo">
<LayoutLogo :collapse="isLogoCollapsed" />
@@ -26,6 +26,7 @@
<script setup lang="ts">
import { useWindowSize } from "@vueuse/core";
import { useLayout } from "./useLayout";
import { useAppStore } from "@/stores";
import { usePermissionStore } from "@/stores";
import BaseLayout from "./BaseLayout.vue";
import LayoutLogo from "./components/LayoutLogo.vue";
@@ -35,6 +36,7 @@ import LayoutTagsView from "./components/LayoutTagsView.vue";
import LayoutMain from "./components/LayoutMain.vue";
const { showTagsView, showLogo } = useLayout();
const appStore = useAppStore();
const { width } = useWindowSize();
const permissionStore = usePermissionStore();
@@ -58,6 +60,7 @@ const isLogoCollapsed = computed(() => width.value < 768);
width: 100%;
height: $navbar-height;
background-color: $menu-background;
border-bottom: 1px solid var(--card-border);
&-left {
display: flex;

View File

@@ -79,7 +79,7 @@ const transitionName = computed(() => {
.app-main {
position: relative;
overflow-y: auto;
background-color: var(--el-bg-color-page);
background-color: var(--page-bg);
/* fade */
.fade-enter-active,

View File

@@ -33,6 +33,10 @@ function toggleSideBar() {
align-items: center;
justify-content: space-between;
height: $navbar-height;
padding: 0 16px 0 4px;
background-color: var(--content-bg);
border-bottom: 1px solid var(--card-border);
box-shadow: 0 1px 2px 0 rgb(0 0 0 / 3%);
&__actions {
display: flex;

View File

@@ -1,66 +1,139 @@
<template>
<div class="tags-container">
<!-- 水平滚动容器 -->
<!-- 页签滚动区 -->
<el-scrollbar
ref="scrollbarRef"
class="scroll-container"
:view-style="{ height: '100%' }"
@wheel="handleScroll"
>
<div h-full flex-y-center gap-8px>
<el-tag
<div class="tags-wrapper">
<div
v-for="tag in visitedViews"
:key="tag.fullPath"
h-26px
cursor-pointer
:closable="!tag.affix"
:effect="tagsViewStore.isActive(tag) ? 'dark' : 'light'"
:type="tagsViewStore.isActive(tag) ? 'primary' : 'info'"
@click.middle="handleMiddleClick(tag)"
@contextmenu.prevent="(event: MouseEvent) => openContextMenu(tag, event)"
@close="closeSelectedTag(tag)"
class="tags-item"
:class="{
'is-active': tagsViewStore.isActive(tag),
'is-affix': tag.affix,
}"
@click="
router.push({
path: tag.fullPath,
query: tag.query,
})
"
@click.middle="handleMiddleClick(tag)"
@contextmenu.prevent="openContextMenu(tag, $event)"
>
{{ translateRouteTitle(tag.title) }}
</el-tag>
<template v-if="tag.icon">
<el-icon v-if="isEpIcon(tag.icon)" :size="14">
<component :is="toEpIconName(tag.icon)" />
</el-icon>
<span v-else class="tags-item__icon" :class="`i-svg:${tag.icon}`" />
</template>
<span class="tags-item__text">
{{ translateRouteTitle(tag.title) }}
</span>
<span v-if="!tag.affix" class="tags-item__close" @click.stop="closeSelectedTag(tag)">
<div class="i-svg:close" />
</span>
</div>
</div>
</el-scrollbar>
<!-- 标签右键菜单 -->
<!-- 右侧操作 -->
<div class="tags-actions">
<!-- 刷新当前 -->
<span class="tags-actions__btn" @click="refreshSelectedTag(currentTag)">
<el-icon :size="16"><Refresh /></el-icon>
</span>
<!-- 内容全屏 -->
<span class="tags-actions__btn" @click="appStore.toggleContentFullscreen()">
<el-icon :size="16">
<FullScreen v-if="!appStore.contentFullscreen" />
<Close v-else />
</el-icon>
</span>
<!-- 下拉菜单 -->
<el-dropdown trigger="click" @command="handleActionCommand">
<span class="tags-actions__btn">
<el-icon :size="16"><ArrowDown /></el-icon>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="refresh">
<el-icon :size="14" class="tags-actions__dropdown-icon">
<Refresh />
</el-icon>
<span>刷新当前</span>
</el-dropdown-item>
<el-dropdown-item v-if="currentTag && !currentTag.affix" command="closeCurrent">
<el-icon :size="14" class="tags-actions__dropdown-icon">
<Close />
</el-icon>
<span>关闭当前</span>
</el-dropdown-item>
<el-dropdown-item divided command="closeOthers">
<el-icon :size="14" class="tags-actions__dropdown-icon">
<Remove />
</el-icon>
<span>关闭其它</span>
</el-dropdown-item>
<el-dropdown-item command="closeLeft">
<el-icon :size="14" class="tags-actions__dropdown-icon">
<DArrowLeft />
</el-icon>
<span>关闭左侧</span>
</el-dropdown-item>
<el-dropdown-item command="closeRight">
<el-icon :size="14" class="tags-actions__dropdown-icon">
<DArrowRight />
</el-icon>
<span>关闭右侧</span>
</el-dropdown-item>
<el-dropdown-item divided command="closeAll">
<el-icon :size="14" class="tags-actions__dropdown-icon">
<CircleClose />
</el-icon>
<span>关闭所有</span>
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
<!-- 右键菜单 -->
<Teleport to="body">
<ul
v-show="contextMenu.visible"
class="contextmenu"
:style="{ left: contextMenu.x + 'px', top: contextMenu.y + 'px' }"
>
<ul v-show="contextMenu.visible" class="contextmenu" :style="contextMenuStyle">
<li @click="refreshSelectedTag(selectedTag)">
<div class="i-svg:refresh" />
刷新
<el-icon :size="16" class="contextmenu__icon"><Refresh /></el-icon>
<span>刷新</span>
</li>
<li v-if="!selectedTag?.affix" @click="closeSelectedTag(selectedTag)">
<div class="i-svg:close" />
关闭
<li
v-if="!selectedTag?.affix"
class="contextmenu__danger"
@click="closeSelectedTag(selectedTag)"
>
<el-icon :size="16" class="contextmenu__icon"><Close /></el-icon>
<span>关闭</span>
</li>
<li class="contextmenu__divider" />
<li @click="closeOtherTags">
<div class="i-svg:close_other" />
关闭其它
<el-icon :size="16" class="contextmenu__icon"><Remove /></el-icon>
<span>关闭其它</span>
</li>
<li v-if="!isFirstView" @click="closeLeftTags">
<div class="i-svg:close_left" />
关闭左侧
<el-icon :size="16" class="contextmenu__icon"><DArrowLeft /></el-icon>
<span>关闭左侧</span>
</li>
<li v-if="!isLastView" @click="closeRightTags">
<div class="i-svg:close_right" />
关闭右侧
<el-icon :size="16" class="contextmenu__icon"><DArrowRight /></el-icon>
<span>关闭右侧</span>
</li>
<li class="contextmenu__divider" />
<li @click="closeAllTags(selectedTag)">
<div class="i-svg:close_all" />
关闭所有
<el-icon :size="16" class="contextmenu__icon"><CircleClose /></el-icon>
<span>关闭所有</span>
</li>
</ul>
</Teleport>
@@ -71,7 +144,7 @@
import { useRoute, useRouter, type RouteRecordRaw } from "vue-router";
import { resolve } from "path-browserify";
import { translateRouteTitle } from "@/lang/utils";
import { usePermissionStore, useTagsViewStore } from "@/stores";
import { useAppStore, usePermissionStore, useTagsViewStore } from "@/stores";
interface ContextMenu {
visible: boolean;
@@ -82,26 +155,79 @@ interface ContextMenu {
const router = useRouter();
const route = useRoute();
// 状态管理
const appStore = useAppStore();
const permissionStore = usePermissionStore();
const tagsViewStore = useTagsViewStore();
const { visitedViews } = storeToRefs(tagsViewStore);
// 当前选中的标签
const selectedTag = ref<TagView | null>(null);
// 右键菜单状态"
const contextMenu = reactive<ContextMenu>({
visible: false,
x: 0,
y: 0,
});
// 滚动条引用
const scrollbarRef = ref();
// 路由映射缓存,提升查找性能
const currentTag = computed(() => {
return visitedViews.value.find((tag) => tagsViewStore.isActive(tag)) || null;
});
// 图标兼容:判断是否为 Element Plus 图标 (el-icon-xxx)
const isEpIcon = (icon: string) => icon.startsWith("el-icon");
// 图标兼容:el-icon-xxx → PascalCase 组件名
const toEpIconName = (icon: string) =>
icon.replace("el-icon-", "").replace(/(^|-)\w/g, (s) => s.slice(-1).toUpperCase());
const contextMenuStyle = computed(() => {
const menuWidth = 160;
const menuHeight = 220;
const padding = 8;
let x = contextMenu.x;
let y = contextMenu.y;
if (x + menuWidth > window.innerWidth) {
x = window.innerWidth - menuWidth - padding;
}
if (y + menuHeight > window.innerHeight) {
y = window.innerHeight - menuHeight - padding;
}
return {
left: `${Math.max(padding, x)}px`,
top: `${Math.max(padding, y)}px`,
};
});
// 标签栏右侧操作按钮分发
const handleActionCommand = (command: string) => {
switch (command) {
case "refresh":
refreshSelectedTag(currentTag.value);
break;
case "closeCurrent":
closeSelectedTag(currentTag.value);
break;
case "closeOthers":
closeOtherTagsForActive();
break;
case "closeLeft":
closeLeftTagsForActive();
break;
case "closeRight":
closeRightTagsForActive();
break;
case "closeAll":
closeAllTags(currentTag.value);
break;
}
};
// path → TagView 映射,用于快速查找标签
const routePathMap = computed(() => {
const map = new Map<string, TagView>();
visitedViews.value.forEach((tag) => {
@@ -110,7 +236,7 @@ const routePathMap = computed(() => {
return map;
});
// 判断是否为第一个标签
// 判断右键选中标签是否为最左侧(不可关闭左侧)
const isFirstView = computed(() => {
if (!selectedTag.value) return false;
return (
@@ -119,15 +245,13 @@ const isFirstView = computed(() => {
);
});
// 判断是否为最后一个标签
// 判断右键选中标签是否为最右侧(不可关闭右侧)
const isLastView = computed(() => {
if (!selectedTag.value) return false;
return selectedTag.value.fullPath === visitedViews.value[visitedViews.value.length - 1]?.fullPath;
});
/**
* 递归提取固定标签
*/
// 从路由表中提取所有 affix 固定标签
const extractAffixTags = (routes: RouteRecordRaw[], basePath = "/"): TagView[] => {
const affixTags: TagView[] = [];
@@ -135,19 +259,18 @@ const extractAffixTags = (routes: RouteRecordRaw[], basePath = "/"): TagView[] =
routeList.forEach((route) => {
const fullPath = resolve(currentBasePath, route.path);
// 如果是固定标签,添加到列表
if (route.meta?.affix) {
affixTags.push({
path: fullPath,
fullPath,
name: String(route.name || ""),
title: route.meta.title || "no-name",
icon: route.meta.icon,
affix: true,
keepAlive: route.meta.keepAlive || false,
});
}
// 递归处理子路由
if (route.children?.length) {
traverse(route.children, fullPath);
}
@@ -158,9 +281,6 @@ const extractAffixTags = (routes: RouteRecordRaw[], basePath = "/"): TagView[] =
return affixTags;
};
/**
* 初始化固定标签
*/
const initAffixTags = () => {
const affixTags = extractAffixTags(permissionStore.routes);
@@ -171,9 +291,6 @@ const initAffixTags = () => {
});
};
/**
* 添加当前路由标签
*/
const addCurrentTag = () => {
if (!route.meta?.title) return;
@@ -182,15 +299,13 @@ const addCurrentTag = () => {
title: route.meta.title,
path: route.path,
fullPath: route.fullPath,
icon: route.meta.icon,
affix: route.meta.affix || false,
keepAlive: route.meta.keepAlive || false,
query: route.query,
});
};
/**
* 更新当前标签
*/
const updateCurrentTag = () => {
nextTick(() => {
const currentTag = routePathMap.value.get(route.path);
@@ -201,6 +316,7 @@ const updateCurrentTag = () => {
title: route.meta?.title || "",
path: route.path,
fullPath: route.fullPath,
icon: route.meta?.icon,
affix: route.meta?.affix || false,
keepAlive: route.meta?.keepAlive || false,
query: route.query,
@@ -209,36 +325,24 @@ const updateCurrentTag = () => {
});
};
/**
* 处理中键点击
*/
const handleMiddleClick = (tag: TagView) => {
if (!tag.affix) {
closeSelectedTag(tag);
}
};
/**
* 打开右键菜单
*/
const openContextMenu = (tag: TagView, event: MouseEvent) => {
contextMenu.x = event.clientX;
contextMenu.y = event.clientY;
contextMenu.visible = true;
selectedTag.value = tag;
};
/**
* 关闭右键菜单
*/
const closeContextMenu = () => {
contextMenu.visible = false;
};
/**
* 处理滚轮事件
*/
// 鼠标滚轮横向滚动(标签栏超出可视区时生效)
const handleScroll = (event: WheelEvent) => {
closeContextMenu();
@@ -254,9 +358,7 @@ const handleScroll = (event: WheelEvent) => {
scrollbarRef.value.setScrollLeft(newScrollLeft);
};
/**
* 刷新标签
*/
// 刷新指定标签页:移除缓存后跳转 redirect 中转页重新加载
const refreshSelectedTag = (tag: TagView | null) => {
if (!tag) return;
@@ -266,9 +368,7 @@ const refreshSelectedTag = (tag: TagView | null) => {
});
};
/**
* 关闭标签
*/
// 关闭指定标签页,若关闭的是当前激活标签则跳转到最后一个
const closeSelectedTag = (tag: TagView | null) => {
if (!tag) return;
@@ -279,58 +379,75 @@ const closeSelectedTag = (tag: TagView | null) => {
});
};
/**
* 关闭左侧标签
*/
// 关闭当前激活标签以外的其他标签
const closeOtherTagsForActive = () => {
if (!currentTag.value) return;
tagsViewStore.delOtherViews(currentTag.value).then(() => {
updateCurrentTag();
});
};
// 关闭当前激活标签左侧的所有标签
const closeLeftTagsForActive = () => {
if (!currentTag.value) return;
tagsViewStore.delLeftViews(currentTag.value).then((result: any) => {
const hasCurrentRoute = result.visitedViews.some((item: TagView) => item.path === route.path);
if (!hasCurrentRoute) {
tagsViewStore.toLastView(result.visitedViews);
}
});
};
// 关闭当前激活标签右侧的所有标签
const closeRightTagsForActive = () => {
if (!currentTag.value) return;
tagsViewStore.delRightViews(currentTag.value).then((result: any) => {
const hasCurrentRoute = result.visitedViews.some((item: TagView) => item.path === route.path);
if (!hasCurrentRoute) {
tagsViewStore.toLastView(result.visitedViews);
}
});
};
// 关闭右键选中标签左侧的所有标签
const closeLeftTags = () => {
if (!selectedTag.value) return;
tagsViewStore.delLeftViews(selectedTag.value).then((result: any) => {
const hasCurrentRoute = result.visitedViews.some((item: TagView) => item.path === route.path);
if (!hasCurrentRoute) {
tagsViewStore.toLastView(result.visitedViews);
}
});
};
/**
* 关闭右侧标签
*/
// 关闭右键选中标签右侧的所有标签
const closeRightTags = () => {
if (!selectedTag.value) return;
tagsViewStore.delRightViews(selectedTag.value).then((result: any) => {
const hasCurrentRoute = result.visitedViews.some((item: TagView) => item.path === route.path);
if (!hasCurrentRoute) {
tagsViewStore.toLastView(result.visitedViews);
}
});
};
/**
* 关闭其他标签
*/
// 关闭右键选中标签以外的其他标签
const closeOtherTags = () => {
if (!selectedTag.value) return;
router.push(selectedTag.value);
tagsViewStore.delOtherViews(selectedTag.value).then(() => {
updateCurrentTag();
});
};
/**
* 关闭所有标签
*/
// 关闭所有标签页
const closeAllTags = (tag: TagView | null) => {
tagsViewStore.delAllViews().then((result: any) => {
tagsViewStore.toLastView(result.visitedViews, tag || undefined);
});
};
// 右键菜单管理
// 右键菜单生命周期:显示时监听外部点击关闭,卸载时移除监听
const useContextMenuManager = () => {
const handleOutsideClick = () => {
closeContextMenu();
@@ -344,13 +461,12 @@ const useContextMenuManager = () => {
}
});
// 组件卸载时清理
onBeforeUnmount(() => {
document.removeEventListener("click", handleOutsideClick);
});
};
// 监听路由变化
// 路由变化时同步添加标签并更新激活状态
watch(
route,
() => {
@@ -360,51 +476,259 @@ watch(
{ immediate: true }
);
// 初始化
// 初始化固定标签(affix)
onMounted(() => {
initAffixTags();
});
// 启用右键菜单管理
useContextMenuManager();
</script>
<style lang="scss" scoped>
// ============================================
// 容器
// ============================================
.tags-container {
position: relative;
z-index: 10;
display: flex;
align-items: center;
width: 100%;
height: $tags-view-height;
padding: 0 15px;
border-top: 1px solid var(--el-border-color-light);
padding: 0 4px 0 8px;
background-color: var(--content-bg);
border-bottom: 1px solid var(--border-color);
}
.scroll-container {
white-space: nowrap;
.scroll-container {
flex: 1;
min-width: 0;
height: 100%;
:deep(.el-scrollbar__wrap) {
overflow-y: hidden;
}
}
// ============================================
// 标签列表
// ============================================
.tags-wrapper {
display: flex;
gap: 6px;
align-items: center;
height: 100%;
}
// ============================================
// 标签项
// 未激活: 透明底,融入白色容器背景
// 激活: 浅主色底 + 边框,从背景中"跳"出来
// ============================================
.tags-item {
display: inline-flex;
flex-shrink: 0;
gap: 6px;
align-items: center;
height: 30px;
padding: 0 14px;
font-size: 13px;
color: var(--el-text-color-regular);
cursor: pointer;
user-select: none;
background: transparent;
border: 1px solid transparent;
border-radius: 8px;
transition:
color 0.15s ease,
background-color 0.15s ease,
border-color 0.15s ease;
&__icon {
flex-shrink: 0;
width: 14px;
height: 14px;
opacity: 0.5;
transition: opacity 0.15s ease;
}
&__text {
white-space: nowrap;
}
&__close {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
margin-left: 1px;
font-size: 0;
border-radius: 4px;
opacity: 0;
transition:
opacity 0.12s ease,
background-color 0.12s ease;
:deep(div) {
width: 12px;
height: 12px;
}
&:hover {
background-color: rgb(0 0 0 / 10%);
}
}
// 悬浮态 — 浅灰底浮现
&:hover {
color: var(--el-text-color-primary);
background-color: var(--el-fill-color-light);
.tags-item__close {
opacity: 1;
}
.tags-item__icon {
opacity: 0.7;
}
}
// 激活态 — 浅主色块突出显示
&.is-active {
font-weight: 500;
color: var(--el-color-primary);
background-color: var(--el-color-primary-light-9);
border-color: var(--el-color-primary-light-7);
.tags-item__icon {
color: var(--el-color-primary);
opacity: 1;
}
.tags-item__close {
opacity: 0.6;
&:hover {
background-color: var(--el-color-primary-light-7);
opacity: 1;
}
}
}
// 固定标签 — 不可关闭,视觉上与未激活一致
&.is-affix {
.tags-item__text {
font-weight: 500;
}
}
}
// ============================================
// 右侧操作按钮
// ============================================
.tags-actions {
display: flex;
flex-shrink: 0;
gap: 2px;
align-items: center;
height: 100%;
padding-left: 4px;
&__btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
cursor: pointer;
border-radius: 6px;
transition: background-color 0.15s ease;
&:hover {
background-color: var(--el-fill-color-light);
}
:deep(.el-icon) {
opacity: 0.45;
}
&:hover :deep(.el-icon) {
opacity: 0.7;
}
}
&__dropdown-icon {
flex-shrink: 0;
margin-right: 6px;
opacity: 0.55;
}
}
// ============================================
// 右键菜单
// ============================================
.contextmenu {
position: absolute;
position: fixed;
z-index: 3000;
padding: 5px 0;
min-width: 150px;
padding: 6px;
margin: 0;
font-size: 12px;
font-weight: 400;
font-size: 13px;
color: var(--el-text-color-primary);
list-style-type: none;
background: var(--el-bg-color);
border-radius: 4px;
box-shadow: var(--el-box-shadow-light);
background: var(--el-bg-color-overlay);
border: 1px solid var(--el-border-color-lighter);
border-radius: 10px;
box-shadow: var(--el-box-shadow);
li {
display: flex;
gap: 8px;
gap: 10px;
align-items: center;
padding: 7px 16px;
margin: 0;
padding: 7px 10px;
margin: 1px 0;
cursor: pointer;
transition: background-color 0.2s;
border-radius: 6px;
transition: background-color 0.15s ease;
&:hover {
background: var(--el-fill-color-light);
}
}
&__icon {
flex-shrink: 0;
opacity: 0.55;
}
&__danger {
&:hover {
color: var(--el-color-danger);
background-color: var(--el-color-danger-light-9);
.contextmenu__icon {
opacity: 1;
}
}
}
&__divider {
height: 1px;
padding: 0 !important;
margin: 5px 8px !important;
pointer-events: none;
cursor: default !important;
background-color: var(--el-border-color-lighter);
&:hover {
background-color: var(--el-border-color-lighter) !important;
}
}
}
</style>

View File

@@ -210,19 +210,19 @@ function handleSettingsClick() {
:deep(.i-svg\:language) {
flex-shrink: 0;
width: 18px;
height: 18px;
font-size: 18px;
line-height: 18px;
background-size: 18px 18px;
width: 16px;
height: 16px;
font-size: 16px;
line-height: 16px;
background-size: 16px 16px;
}
// 图标样式
:deep([class^="i-svg:"]) {
font-size: 18px;
font-size: 16px;
line-height: 1;
color: var(--el-text-color-regular);
transition: color 0.3s;
color: var(--el-text-color-secondary);
transition: color 0.2s;
}
&:hover {
@@ -297,7 +297,7 @@ function handleSettingsClick() {
.navbar-actions--dark-text {
.navbar-actions__item {
:deep([class^="i-svg:"]) {
color: var(--el-text-color-regular) !important;
color: var(--el-text-color-secondary) !important;
}
&:hover {

View File

@@ -25,8 +25,8 @@ export const appConfig = {
// ============================================
export const defaults = {
theme: prefersDark ? ThemeMode.DARK : ThemeMode.LIGHT,
themeColor: "#4080FF",
sidebarColorScheme: SidebarColor.CLASSIC_BLUE,
themeColor: "#1677FF",
sidebarColorScheme: SidebarColor.MINIMAL_WHITE,
layout: LayoutMode.LEFT,
size: ComponentSize.DEFAULT,
language: LanguageEnum.ZH_CN,
@@ -42,14 +42,14 @@ export const defaults = {
// 主题色预设
// ============================================
export const themeColorPresets = [
"#4080FF",
"#1890FF",
"#409EFF",
"#FA8C16",
"#1677FF",
"#165DFF",
"#3370FF",
"#22C55E",
"#FAAD14",
"#FF4D4F",
"#722ED1",
"#13C2C2",
"#52C41A",
"#F5222D",
"#2F54EB",
"#EB2F96",
"#13C2C2",
"#F97316",
] as const;

View File

@@ -15,6 +15,7 @@ export const useAppStore = defineStore("app", () => {
withoutAnimation: false,
});
const activeTopMenuPath = useStorage(STORAGE_KEYS.ACTIVE_TOP_MENU_PATH, "");
const contentFullscreen = ref(false);
const locale = computed(() => (language?.value === "en" ? en : zhCn));
@@ -49,6 +50,10 @@ export const useAppStore = defineStore("app", () => {
activeTopMenuPath.value = val;
}
function toggleContentFullscreen() {
contentFullscreen.value = !contentFullscreen.value;
}
return {
device,
sidebar,
@@ -56,12 +61,14 @@ export const useAppStore = defineStore("app", () => {
locale,
size,
activeTopMenu,
contentFullscreen,
toggleDevice,
changeSize,
changeLanguage,
toggleSidebar,
closeSideBar,
openSideBar,
toggleContentFullscreen,
activeTopMenuPath,
};
});

View File

@@ -32,6 +32,12 @@ export const useSettingsStore = defineStore("setting", () => {
// 主题
const theme = useStorage<ThemeMode>(STORAGE_KEYS.THEME, defaults.theme);
const themeColor = useStorage(STORAGE_KEYS.THEME_COLOR, defaults.themeColor);
// 旧默认值 → 新默认值 自动迁移(用户自定义的颜色不会被覆盖)
const LEGACY_DEFAULTS = ["#4080FF", "#4080ff", "#2563EB", "#2563eb"];
if (LEGACY_DEFAULTS.includes(themeColor.value)) {
themeColor.value = defaults.themeColor;
}
const resolvedTheme = ref<ThemeMode>(resolveThemeMode(theme.value));
// 特殊模式

View File

@@ -218,6 +218,7 @@ export const useTagsViewStore = defineStore("tagsView", () => {
title: route.meta.title as string,
path: route.path,
fullPath: route.fullPath,
icon: route.meta?.icon as string | undefined,
affix: route.meta?.affix,
keepAlive: route.meta?.keepAlive,
query: route.query,

View File

@@ -36,11 +36,32 @@ body {
"Helvetica Neue", Helvetica, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "微软雅黑",
Arial, sans-serif;
line-height: inherit;
background-color: var(--page-bg);
-moz-osx-font-smoothing: grayscale;
-webkit-font-smoothing: antialiased;
text-rendering: optimizelegibility;
}
/* 全局平滑滚动 */
html {
scroll-behavior: smooth;
}
/* 滚动条现代化 */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-thumb {
background-color: rgb(0 0 0 / 12%);
border-radius: 3px;
}
::-webkit-scrollbar-track {
background-color: transparent;
}
img,
svg {
display: inline-block;

View File

@@ -1,6 +1,5 @@
/**
* 主题变量定义
* 只放 :root、html.dark、html.sidebar-color-blue 等定义真实颜色的 CSS 变量
*/
// ============================================
@@ -15,8 +14,42 @@
--menu-hover: #e6f4ff;
// 侧边栏 Logo
--sidebar-logo-background: #f5f5f5;
--sidebar-logo-text-color: #303133;
--sidebar-logo-background: #fafafa;
--sidebar-logo-text-color: #383853;
// 页面背景与内容区
--page-bg: #fafbfc;
--content-bg: #ffffff;
// 边框
--border-color: #e2e8ee;
--card-border: rgb(0 0 0 / 8%);
// 卡片
--card-radius: calc(var(--custom-radius) + 4px);
--card-shadow: 0 1px 3px 0 rgb(0 0 0 / 6%), 0 1px 2px -1px rgb(0 0 0 / 4%);
// 全局圆角基准(可被 settings 动态覆盖)
--custom-radius: 8px;
// 页面布局间距
--page-padding: 20px;
--page-gap: 16px;
// 灰色阶
--gray-100: #f9fafb;
--gray-200: #f2f4f5;
--gray-300: #e6eaeb;
--gray-400: #dbdfe1;
--gray-500: #949eb7;
--gray-600: #7987a1;
--gray-700: #4d5875;
--gray-800: #383853;
--gray-900: #323251;
// 悬浮与激活
--hover-color: #edeff0;
--active-color: #f2f4f5;
}
// ============================================
@@ -44,7 +77,25 @@ html.dark {
--sidebar-logo-background: rgb(0 0 0 / 20%);
--sidebar-logo-text-color: #fff;
// Element Plus 表格选中行背景色
--page-bg: #070707;
--content-bg: #161618;
--border-color: rgb(255 255 255 / 10%);
--card-border: rgb(255 255 255 / 8%);
--card-shadow: 0 1px 3px 0 rgb(0 0 0 / 40%), 0 1px 2px -1px rgb(0 0 0 / 30%);
--gray-100: #110f0f;
--gray-200: #17171c;
--gray-300: #393946;
--gray-400: #505062;
--gray-500: #73738c;
--gray-600: #8f8fa3;
--gray-700: #ababba;
--gray-800: #c7c7d1;
--gray-900: #e3e3e8;
--hover-color: #252530;
--active-color: #202226;
.el-table {
--el-table-current-row-bg-color: var(--el-fill-color-light);
}
@@ -54,7 +105,6 @@ html.dark {
// 无障碍模式
// ============================================
// 色弱模式
html.color-weak {
filter: invert(80%);
}

View File

@@ -10,7 +10,7 @@
$sidebar-width: 210px;
$sidebar-width-collapsed: 54px;
$navbar-height: 50px;
$tags-view-height: 34px;
$tags-view-height: 40px;
// ============================================
// CSS 变量映射(供组件和 SCSS 使用)

View File

@@ -3,11 +3,11 @@
*/
.page-container {
padding: 16px;
padding: var(--page-padding);
}
.page-aside {
margin-bottom: 16px;
margin-bottom: var(--page-gap);
@media (min-width: 992px) {
margin-bottom: 0;
@@ -17,15 +17,18 @@
.page-main {
display: flex;
flex-direction: column;
gap: 16px;
gap: var(--page-gap);
}
.page-search {
margin-bottom: 16px;
margin-bottom: var(--page-gap);
/* 覆盖 el-card 内部 padding */
.el-card__body {
padding: 12px 12px 0;
padding: 14px 16px 4px;
}
.el-form-item {
margin-bottom: 10px;
}
}
@@ -39,7 +42,7 @@
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
margin-bottom: var(--page-gap);
&__left,
&__right {

View File

@@ -2,14 +2,46 @@
* Element Plus 组件样式定制
*/
$border: 1px solid var(--el-border-color-light);
$border: 1px solid var(--border-color);
/* el-dialog */
/* 全局组件圆角基准 */
:root {
--el-border-radius-base: 6px;
--el-border-radius-small: 4px;
--el-font-weight-primary: 400;
}
/* 卡片 — 边框模式 */
.el-card {
border: 1px solid var(--card-border);
border-radius: var(--card-radius);
box-shadow: var(--card-shadow);
transition: box-shadow 0.2s ease;
&:hover {
box-shadow:
0 4px 12px 0 rgb(0 0 0 / 8%),
0 1px 3px 0 rgb(0 0 0 / 4%);
}
.el-card__header {
padding: 16px 20px;
border-bottom: 1px solid var(--card-border);
}
.el-card__body {
padding: 20px;
}
}
/* 弹窗 */
.el-dialog {
border-radius: 12px;
.el-dialog__header {
padding: 15px 20px;
padding: 20px 20px 0;
margin: 0;
border-bottom: $border;
border-bottom: none;
}
.el-dialog__body {
@@ -17,15 +49,15 @@ $border: 1px solid var(--el-border-color-light);
}
.el-dialog__footer {
padding: 15px;
border-top: $border;
padding: 0 20px 20px;
border-top: none;
}
}
/* el-drawer */
/* 抽屉 */
.el-drawer {
.el-drawer__header {
padding: 15px 20px;
padding: 16px 20px;
margin: 0;
color: inherit;
border-bottom: $border;
@@ -36,11 +68,89 @@ $border: 1px solid var(--el-border-color-light);
}
.el-drawer__footer {
padding: 15px;
padding: 16px;
border-top: $border;
}
}
/* el-table */
.el-table {
--el-table-border-color: var(--border-color);
--el-table-header-bg-color: var(--gray-100);
--el-table-row-hover-bg-color: var(--el-fill-color-lighter);
--el-table-current-row-bg-color: var(--el-fill-color-light);
border-radius: var(--custom-radius);
/* 表头 */
.el-table__header th {
padding: 12px 0;
font-weight: 500;
color: var(--el-text-color-regular);
border-bottom: 1px solid var(--border-color);
}
/* 单元格 */
.el-table__body td {
padding: 10px 0;
}
/* 无数据时不显示底部边框 */
.el-table__empty-block {
border-bottom: none;
}
}
/* 斑马纹 — 更柔和 */
.el-table--striped .el-table__body tr.el-table__row--striped td {
background-color: rgb(0 0 0 / 1.5%);
}
html.dark .el-table--striped .el-table__body tr.el-table__row--striped td {
background-color: rgb(255 255 255 / 2%);
}
/* 下拉菜单项 */
.el-dropdown-menu__item,
.el-select-dropdown__item {
margin: 2px 6px;
border-radius: 6px;
}
/* 按钮 */
.el-button {
--el-button-border-radius: var(--el-border-radius-base);
letter-spacing: 0.01em;
&:not(.is-link):not(.el-button--large):not(.el-button--small) {
padding: 9px 16px;
}
}
.el-button--default {
--el-button-border-color: var(--border-color);
&:hover:not(.is-disabled) {
color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
border-color: var(--el-color-primary-light-5);
}
}
.el-button--primary,
.el-button--success,
.el-button--warning,
.el-button--danger {
&:hover:not(.is-disabled) {
box-shadow: 0 2px 6px 0 var(--el-button-bg-color);
}
}
/* 标签 */
.el-tag {
border-radius: 4px;
}
/* 抽屉和对话框底部按钮区域 */
.dialog-footer {
display: flex;
@@ -56,6 +166,7 @@ $border: 1px solid var(--el-border-color-light);
--el-menu-hover-bg-color: var(--menu-hover);
background-color: var(--menu-background);
border-radius: 8px;
.el-menu-item,
.el-sub-menu__title {

View File

@@ -6,19 +6,19 @@
$colors: (
"primary": (
// 默认主题色 - 修改此值时需同步修改 src/settings.ts 中的 themeColor
"base": #4080ff,
"base": #1677ff,
),
"success": (
"base": #23c343,
"base": #22c55e,
),
"warning": (
"base": #ff9a2e,
"base": #faad14,
),
"danger": (
"base": #f76560,
"base": #ff4d4f,
),
"info": (
"base": #a9aeb8,
"base": #788896,
),
),

View File

@@ -59,7 +59,7 @@ export function generateThemeColors(primary: string, theme: ThemeMode) {
primary,
};
// 生成浅色变体
// 生成 primary 浅色/深色变体
for (let i = 1; i <= 9; i++) {
colors[`primary-light-${i}`] =
resolvedTheme === ThemeMode.LIGHT
@@ -67,12 +67,33 @@ export function generateThemeColors(primary: string, theme: ThemeMode) {
: `${getDarkColor(primary, i / 10)}`;
}
// 生成深色变体
colors["primary-dark-2"] =
resolvedTheme === ThemeMode.LIGHT
? `${getLightColor(primary, 0.2)}`
: `${getDarkColor(primary, 0.3)}`;
// 语义色(success / warning / danger / info)
const semanticColors: Record<string, string> = {
success: "#22c55e",
warning: "#faad14",
danger: "#ff4d4f",
info: "#788896",
};
Object.entries(semanticColors).forEach(([name, base]) => {
colors[name] = base;
for (let i = 1; i <= 9; i++) {
colors[`${name}-light-${i}`] =
resolvedTheme === ThemeMode.LIGHT
? `${getLightColor(base, i / 10)}`
: `${getDarkColor(base, i / 10)}`;
}
colors[`${name}-dark-2`] =
resolvedTheme === ThemeMode.LIGHT
? `${getLightColor(base, 0.2)}`
: `${getDarkColor(base, 0.3)}`;
});
return colors;
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,21 @@
<!-- 图标选择器示例 -->
<template>
<div class="page-container">
<el-link
href="https://gitee.com/youlaiorg/vue3-element-admin/blob/master/src/views/demo/icon-select.vue"
type="primary"
target="_blank"
class="mb-10"
>
示例源码 请点击>>>
</el-link>
<icon-select v-model="iconName" />
</div>
</template>
<script setup lang="ts">
// element-plus 图标格式以el-icon-开头
const iconName = ref("el-icon-edit");
// 本地SVG图标格式为src/assets/icons 下的文件名,不需要svg后缀
// const iconName = ref("api");
</script>

View File

@@ -1,293 +0,0 @@
<template>
<div class="auth-panel-form">
<h3 class="auth-panel-form__title" text-center>{{ t("login.login") }}</h3>
<el-form
ref="loginFormRef"
:model="loginFormData"
:rules="loginRules"
size="large"
:validate-on-rule-change="false"
>
<!-- 用户名 -->
<el-form-item prop="username">
<el-input v-model.trim="loginFormData.username" :placeholder="t('login.username')">
<template #prefix>
<el-icon><User /></el-icon>
</template>
</el-input>
</el-form-item>
<!-- 密码 -->
<el-tooltip :visible="isCapsLock" :content="t('login.capsLock')" placement="right">
<el-form-item prop="password">
<el-input
v-model.trim="loginFormData.password"
:placeholder="t('login.password')"
type="password"
show-password
@keyup="checkCapsLock"
@keyup.enter="handleLoginSubmit"
>
<template #prefix>
<el-icon><Lock /></el-icon>
</template>
</el-input>
</el-form-item>
</el-tooltip>
<!-- 验证码 -->
<el-form-item prop="captchaCode">
<div flex items-center gap-10px>
<el-input
v-model.trim="loginFormData.captchaCode"
:placeholder="t('login.captchaCode')"
clearable
class="flex-1"
@keyup.enter="handleLoginSubmit"
>
<template #prefix>
<div class="i-svg:captcha" />
</template>
</el-input>
<div cursor-pointer h-44px w-140px flex-center @click="getCaptcha">
<el-icon v-if="codeLoading" class="is-loading" size="20"><Loading /></el-icon>
<img
v-else-if="captchaBase64"
border-rd-4px
w-full
h-full
block
object-cover
shadow="[0_0_0_1px_var(--el-border-color)_inset]"
:src="captchaBase64"
alt="captchaCode"
title="点击刷新验证码"
@error="getCaptcha"
/>
<el-text v-else type="info" size="small">点击获取验证码</el-text>
</div>
</div>
</el-form-item>
<div class="flex-x-between w-full">
<el-checkbox v-model="loginFormData.rememberMe">{{ t("login.rememberMe") }}</el-checkbox>
<el-link type="primary" underline="never" @click="toOtherForm('resetPwd')">
{{ t("login.forgetPassword") }}
</el-link>
</div>
<!-- 登录按钮 -->
<el-form-item>
<el-button :loading="loading" type="primary" class="w-full" @click="handleLoginSubmit">
{{ t("login.login") }}
</el-button>
</el-form-item>
</el-form>
<div flex-center gap-10px>
<el-text size="default">{{ t("login.noAccount") }}</el-text>
<el-link type="primary" underline="never" @click="toOtherForm('register')">
{{ t("login.reg") }}
</el-link>
</div>
<!-- 第三方登录 -->
<div class="third-party-login">
<div class="divider-container">
<div class="divider-line"></div>
<span class="divider-text">{{ t("login.otherLoginMethods") }}</span>
<div class="divider-line"></div>
</div>
<div class="social-login">
<div class="social-login__item">
<div class="i-svg:wechat" />
</div>
<div class="social-login__item">
<div class="i-svg:qq" />
</div>
<div class="social-login__item">
<div class="i-svg:github" />
</div>
<div class="social-login__item">
<div class="i-svg:gitee" />
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { FormInstance } from "element-plus";
import AuthAPI from "@/api/auth";
import type { LoginRequest } from "@/api/auth";
import router from "@/router";
import { useUserStore } from "@/stores";
import { AuthStorage } from "@/utils/auth";
const { t } = useI18n();
const userStore = useUserStore();
const route = useRoute();
onMounted(() => getCaptcha());
const loginFormRef = ref<FormInstance>();
const loading = ref(false);
// 是否大写锁定
const isCapsLock = ref(false);
// 验证码图片 Base64
const captchaBase64 = ref();
// 记住我
const rememberMe = AuthStorage.getRememberMe();
const loginFormData = ref<LoginRequest>({
username: "admin",
password: "123456",
captchaId: "",
captchaCode: "",
rememberMe,
});
const loginRules = computed(() => {
return {
username: [
{
required: true,
trigger: "blur",
message: t("login.message.username.required"),
},
],
password: [
{
required: true,
trigger: "blur",
message: t("login.message.password.required"),
},
{
min: 6,
message: t("login.message.password.min"),
trigger: "blur",
},
],
captchaCode: [
{
required: true,
trigger: "blur",
message: t("login.message.captchaCode.required"),
},
],
};
});
// 获取验证码
const codeLoading = ref(false);
function getCaptcha() {
codeLoading.value = true;
AuthAPI.getCaptcha()
.then((data) => {
loginFormData.value.captchaId = data.captchaId;
captchaBase64.value = data.captchaBase64;
})
.finally(() => (codeLoading.value = false));
}
/**
* 登录提交
*/
async function handleLoginSubmit() {
// 1. 表单验证
const valid = await loginFormRef.value?.validate().then(
() => true,
() => false
);
if (!valid) return;
loading.value = true;
try {
// 2. 执行登录
await userStore.login(loginFormData.value).then(
async () => {
// 登录成功,跳转到目标页面
const redirectPath = (route.query.redirect as string) || "/";
await router.push(decodeURIComponent(redirectPath));
},
(error) => {
// 登录失败,刷新验证码
getCaptcha();
throw error;
}
);
} finally {
loading.value = false;
}
}
// 检查输入大小写
function checkCapsLock(event: KeyboardEvent) {
// 防止浏览器密码自动填充时报错
if (event instanceof KeyboardEvent) {
isCapsLock.value = event.getModifierState("CapsLock");
}
}
const emit = defineEmits(["update:modelValue"]);
function toOtherForm(type: "register" | "resetPwd") {
emit("update:modelValue", type);
}
</script>
<style lang="scss" scoped>
.auth-panel-form {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.auth-panel-form__title {
margin: 0 0 0.5rem;
font-size: 1.125rem;
font-weight: 600;
}
.third-party-login {
.divider-container {
display: flex;
align-items: center;
margin: 16px 0;
.divider-line {
flex: 1;
height: 1px;
background: linear-gradient(to right, transparent, var(--el-border-color-light), transparent);
}
.divider-text {
padding: 0 16px;
font-size: 12px;
color: var(--el-text-color-regular);
white-space: nowrap;
}
}
.social-login {
display: flex;
gap: 1.25rem;
align-items: center;
justify-content: center;
width: 100%;
color: var(--el-text-color-secondary);
&__item {
display: flex;
align-items: center;
justify-content: center;
padding: 0.5rem;
font-size: 20px;
cursor: pointer;
border-radius: 8px;
transition: background-color 0.3s ease;
&:hover {
background-color: var(--el-fill-color);
}
}
}
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
<template>
<el-card shadow="never">
<el-input v-model="deptName" placeholder="部门名称" clearable>
<el-input v-model="deptName" placeholder="搜索部门" clearable>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
@@ -8,7 +8,7 @@
<el-tree
ref="deptTreeRef"
class="mt-2"
class="dept-card__tree"
:data="deptList"
:props="{ children: 'children', label: 'label', disabled: '' }"
:expand-on-click-node="false"