refactor: 前端项目代码优化与样式调整

本次提交包含多项改进:
1. 新增移动端断点常量并全局复用,重构响应式设备检测逻辑
2. 优化表格卡片样式与布局,统一移除多余的shadow属性
3. 调整表单、按钮、分页组件的移动端适配样式
4. 补充邮箱国际化文案与图表组件标题属性
5. 优化菜单hover效果、全局搜索样式与深色主题变量
6. 修复权限配置、组件默认值与布局排版问题
7. 简化部分代码冗余逻辑,统一代码风格
This commit is contained in:
zhangtao
2026-06-28 20:50:54 +08:00
parent 50c0493eb2
commit a0c7091697
90 changed files with 2062 additions and 1921 deletions
@@ -1,7 +1,7 @@
<!-- 统计卡片 -->
<template>
<div
class="fa-card h-30 flex items-center px-5 transition-transform duration-200 hover:-translate-y-0.5"
class="fa-card h-32 flex items-center px-5 transition-transform duration-200 hover:-translate-y-0.5"
:class="boxStyle"
>
<div
@@ -40,6 +40,8 @@
</template>
<script setup lang="ts">
import { useWindowSize } from "@vueuse/core";
defineOptions({ name: "FaDragVerify" });
// 事件定义
@@ -98,6 +100,11 @@ const props = withDefaults(defineProps<Props>(), {
textColor: "#333",
});
const { width: winWidth } = useWindowSize();
const effectiveHeight = computed(() =>
props.height === 40 && winWidth.value < 768 ? 24 : props.height
);
// 组件状态接口定义
interface StateType {
isMoving: boolean; // 是否正在拖拽
@@ -188,30 +195,30 @@ onBeforeUnmount(() => {
});
// 滑块样式计算
const handlerStyle = {
const handlerStyle = computed(() => ({
left: "0",
width: props.height + "px",
height: props.height + "px",
width: effectiveHeight.value + "px",
height: effectiveHeight.value + "px",
background: props.handlerBg,
};
}));
// 主容器样式计算
const dragVerifyStyle = computed(() => ({
width: getStyleWidth(),
height: props.height + "px",
lineHeight: props.height + "px",
height: effectiveHeight.value + "px",
lineHeight: effectiveHeight.value + "px",
background: props.background,
borderRadius: props.circle ? props.height / 2 + "px" : props.radius,
borderRadius: props.circle ? effectiveHeight.value / 2 + "px" : props.radius,
}));
// 进度条样式计算
const progressBarStyle = {
const progressBarStyle = computed(() => ({
background: props.progressBarBg,
height: props.height + "px",
height: effectiveHeight.value + "px",
borderRadius: props.circle
? props.height / 2 + "px 0 0 " + props.height / 2 + "px"
? effectiveHeight.value / 2 + "px 0 0 " + effectiveHeight.value / 2 + "px"
: props.radius,
};
}));
// 文本样式计算
const textStyle = computed(() => ({
@@ -94,7 +94,7 @@ const props = withDefaults(defineProps<Props>(), {
filename: () => `export_${new Date().toISOString().slice(0, 10)}`,
sheetName: "Sheet1",
type: "primary",
size: "default",
size: undefined,
disabled: false,
buttonText: "导出 Excel",
loadingText: "导出中...",
@@ -247,7 +247,7 @@ const componentMap = {
const { width } = useWindowSize();
const { t } = useI18n();
const isMobile = computed(() => width.value < 500);
const isMobile = computed(() => width.value < 500); // 表单窄布局阈值
const formInstance = useTemplateRef<FormInstance>("formRef");
@@ -191,7 +191,10 @@ const componentMap = {
const { width } = useWindowSize();
const { t } = useI18n();
const isMobile = computed(() => width.value < 500);
const isMobile = computed(() => width.value < 500); // 表单窄布局阈值
/** H5 压缩模式:收起时只显示 1 个搜索项 */
const h5Compact = computed(() => width.value < 768);
const maxItemsPerRow = computed(() => (h5Compact.value ? 1 : Math.floor(24 / props.span) - 1));
const formInstance = useTemplateRef<FormInstance>("formRef");
@@ -493,10 +496,9 @@ const visibleFormItems = computed(() => {
const filteredItems = mergedItems.value.filter((item) => !item.hidden);
const shouldShowLess = !props.isExpand && !isExpanded.value;
if (shouldShowLess) {
const maxItemsPerRow = Math.floor(24 / props.span) - 1;
// 收起时:只显示非 expandOnly 的字段,且不超过最大数量
const nonExpandOnlyItems = filteredItems.filter((item) => !item.expandOnly);
return nonExpandOnlyItems.slice(0, maxItemsPerRow);
return nonExpandOnlyItems.slice(0, maxItemsPerRow.value);
}
// 展开时:显示所有字段
return filteredItems;
@@ -512,9 +514,7 @@ const visibleItemCount = computed(() => mergedItems.value.filter((item) => !item
* 当合并后的总字段数超过一行可展示数量时显示展开按钮
*/
const shouldShowExpandToggle = computed(() => {
return (
!props.isExpand && props.showExpand && visibleItemCount.value > Math.floor(24 / props.span) - 1
);
return !props.isExpand && props.showExpand && visibleItemCount.value > maxItemsPerRow.value;
});
/**
@@ -636,9 +636,10 @@ const { span, gutter, labelPosition, labelWidth } = toRefs(props);
.action-column {
.action-buttons-wrapper {
flex-direction: column;
flex-direction: row;
gap: 8px;
align-items: stretch;
align-items: center;
justify-content: center;
.form-buttons {
justify-content: center;
@@ -2,7 +2,7 @@
display: flex;
width: 100%;
min-height: 100vh;
background: var(--default-bg-color);
background: var(--app-bg);
#app-sidebar {
flex-shrink: 0;
@@ -74,14 +74,19 @@
height: 100vh;
}
/** H5 端:使用 100dvh(动态视口高度,排除移动浏览器地址栏)
* 让 #app-main 有明确高度,#app-content 的 flex:1 才能撑满,
* 表格区才能像 PC 端一样在视口内独立滚动(而非整页滚动) */
#app-main {
width: 100%;
height: auto;
overflow: visible;
height: 100dvh;
overflow: hidden;
#app-content {
padding: 12px;
:deep(.layout-content) {
width: calc(100% - 40px);
width: calc(100% - 24px);
}
}
}
@@ -92,8 +97,10 @@
.app-layout {
#app-main {
#app-content {
padding: 8px;
:deep(.layout-content) {
width: calc(100% - 30px);
width: calc(100% - 16px);
}
}
}
@@ -95,6 +95,7 @@ import { ElScrollbar } from "element-plus";
import { mittBus } from "@utils";
import meAvatar from "@imgs/avatar/avatar5.webp";
import aiAvatar from "@imgs/avatar/avatar10.webp";
import { MOBILE_BREAKPOINT } from "@/utils/constants/definitions";
defineOptions({ name: "FaChatWindow" });
@@ -109,7 +110,6 @@ interface ChatMessage {
}
// 常量定义
const MOBILE_BREAKPOINT = 640;
const SCROLL_DELAY = 100;
const BOT_NAME = "Art Bot";
const USER_NAME = "Ricky";
@@ -381,7 +381,7 @@ const highlightOnHoverHistory = (index: number) => {
<style lang="scss" scoped>
.layout-search {
:deep(.search-modal) {
background-color: rgb(0 0 0 / 20%);
background-color: var(--fa-overlay-light);
}
:deep(.el-dialog__body) {
@@ -409,18 +409,18 @@ const highlightOnHoverHistory = (index: number) => {
.dark .layout-search {
.el-input {
:deep(.el-input__wrapper) {
background-color: #333;
border: 1px solid #4c4d50;
background-color: var(--fa-input-bg);
border: 1px solid var(--fa-input-border);
}
}
:deep(.search-modal) {
background-color: rgb(23 23 26 / 60%);
background-color: var(--fa-overlay-light);
backdrop-filter: none;
}
:deep(.el-dialog) {
background-color: #252526;
background-color: var(--fa-dialog-bg);
}
}
</style>
@@ -72,14 +72,16 @@
<!-- 搜索 -->
<div
v-if="shouldShowGlobalSearch"
class="flex items-center justify-between w-40 h-9 px-2.5 cursor-pointer border border-g-400 rounded-custom-sm max-md:hidden! transition duration-300 hover:-translate-y-0.5 hover:shadow-md"
class="search-bar-trigger flex items-center justify-between w-40 h-9 px-2.5 cursor-pointer border border-g-400 rounded-custom-sm max-md:hidden! transition duration-300 hover:-translate-y-0.5 hover:shadow-md"
@click="openSearchDialog"
>
<div class="flex items-center">
<FaSvgIcon icon="ri:search-line" class="text-sm text-g-500" />
<span class="ml-1 text-xs font-normal text-g-500">{{ $t("topBar.search.title") }}</span>
</div>
<div class="flex items-center h-5 px-1.5 text-g-500/80 border border-g-400 rounded">
<div
class="flex items-center h-5 px-1.5 text-g-500/80 border border-(--el-color-primary) rounded"
>
<FaSvgIcon v-if="isWindows" icon="vaadin:ctrl-a" class="text-sm" />
<FaSvgIcon v-else icon="ri:command-fill" class="text-xs" />
<span class="ml-0.5 text-xs">k</span>
@@ -493,6 +495,11 @@ const openChat = (): void => {
animation: shrink 0.6s forwards;
}
/* 搜索触发按钮 hover 边框变主题色 */
.search-bar-trigger:hover {
border-color: var(--el-color-primary) !important;
}
.notice-button:hover :deep(.fa-svg-icon) {
animation: shake 0.5s ease-in-out;
}
@@ -199,7 +199,7 @@
<ElButton @click="handleCloseDialog">取消</ElButton>
<ElButton
v-if="activeTabRef !== 'aiModel'"
v-hasPerm="['module_system:config:update']"
v-hasPerm="['module_system:param:update']"
type="primary"
:disabled="!hasChanges"
@click="submitChanges"
@@ -213,10 +213,9 @@
<script lang="ts" setup>
import { ref, reactive, onMounted, computed } from "vue";
import ParamsAPI, { type ConfigTable } from "@/api/module_system/params";
import { useAppStore, useConfigStore } from "@stores";
import { useConfigStore } from "@stores";
import { useI18n } from "vue-i18n";
import { ElMessage, ElMessageBox } from "element-plus";
import { DeviceEnum } from "@/enums/settings/device.enum";
import FaAiModelConfigPanel from "@/views/module_ai/chat/components/FaAiModelConfigPanel.vue";
defineOptions({ name: "FaConfigInfoDrawer" });
@@ -245,8 +244,7 @@ const isValidApiPath = (path: string): boolean => {
return pathRegex.test(path);
};
const appStore = useAppStore();
const drawerSize = computed(() => (appStore.device === DeviceEnum.DESKTOP ? "60%" : "60%"));
const drawerSize = ref("60%");
const t = useI18n().t;
const configStore = useConfigStore();
@@ -112,6 +112,13 @@ async function handleSwitch(tenantId: number) {
height: 32px;
padding: 0 10px;
font-size: 13px;
@media (width <= 640px) {
max-width: 120px;
padding: 0 8px;
font-size: 12px;
}
color: var(--el-text-color-primary);
cursor: pointer;
user-select: none;
@@ -166,8 +173,14 @@ async function handleSwitch(tenantId: number) {
/* 全局样式:下拉面板(scoped 无法深入 ElDropdownMenu) */
.fa-tenant-dropdown {
min-width: 240px;
max-width: calc(100vw - 20px);
padding: 0 !important;
@media (width <= 640px) {
width: 90vw;
min-width: 200px;
}
.el-dropdown-menu__item {
padding: 0 !important;
@@ -57,35 +57,35 @@
</div>
<ul class="py-4 mt-3 border-t border-g-300/80">
<li
class="flex items-center p-2 mb-3 select-none rounded-md cursor-pointer last:mb-0 hover:bg-(--fa-gray-200)"
class="flex items-center p-2 mb-3 select-none rounded-md cursor-pointer last:mb-0 hover:bg-(--el-color-primary)/10"
@click="goPage('/fastlink/profile')"
>
<FaSvgIcon icon="ri:user-3-line" class="mr-2 text-base" />
<span class="text-sm">{{ $t("topBar.user.userCenter") }}</span>
</li>
<li
class="flex items-center p-2 mb-3 select-none rounded-md cursor-pointer last:mb-0 hover:bg-(--fa-gray-200)"
class="flex items-center p-2 mb-3 select-none rounded-md cursor-pointer last:mb-0 hover:bg-(--el-color-primary)/10"
@click="openParamConfig"
>
<FaSvgIcon icon="ri:settings-3-line" class="mr-2 text-base" />
<span class="text-sm">{{ $t("topBar.user.paramConfig") }}</span>
</li>
<li
class="flex items-center p-2 mb-3 select-none rounded-md cursor-pointer last:mb-0 hover:bg-(--fa-gray-200)"
class="flex items-center p-2 mb-3 select-none rounded-md cursor-pointer last:mb-0 hover:bg-(--el-color-primary)/10"
@click="toGithub()"
>
<FaSvgIcon icon="ri:github-line" class="mr-2 text-base" />
<span class="text-sm">{{ $t("topBar.user.github") }}</span>
</li>
<li
class="flex items-center p-2 mb-3 select-none rounded-md cursor-pointer last:mb-0 hover:bg-(--fa-gray-200)"
class="flex items-center p-2 mb-3 select-none rounded-md cursor-pointer last:mb-0 hover:bg-(--el-color-primary)/10"
@click="toGitee"
>
<FaSvgIcon icon="ri:git-branch-line" class="mr-2 text-base" />
<span class="text-sm">{{ $t("topBar.user.gitee") }}</span>
</li>
<li
class="flex items-center p-2 mb-3 select-none rounded-md cursor-pointer last:mb-0 hover:bg-(--fa-gray-200)"
class="flex items-center p-2 mb-3 select-none rounded-md cursor-pointer last:mb-0 hover:bg-(--el-color-primary)/10"
@click="lockScreen()"
>
<FaSvgIcon icon="ri:lock-line" class="mr-2 text-base" />
@@ -17,7 +17,6 @@
v-for="item in filteredMenuItems"
:key="item.path"
:item="item"
:isMobile="false"
:level="0"
/>
</ElMenu>
@@ -106,4 +105,39 @@ const filterMenuItems = (items: AppRouteRecord[]): AppRouteRecord[] => {
padding: 0 30px 0 10px !important;
border: 0 !important;
}
/* Horizontal menu item hover background */
:deep(.el-menu-item:hover),
:deep(.el-sub-menu__title:hover) {
background-color: var(--el-color-primary-light-9) !important;
}
/* 暗色模式:水平菜单内联项(非弹出层)hover */
.dark {
:deep(.el-menu-item:hover),
:deep(.el-sub-menu__title:hover) {
background-color: color-mix(in srgb, var(--el-color-primary) 15%, transparent) !important;
}
}
</style>
<style lang="scss">
/* 水平菜单弹出层 hover 背景(popper 在组件树外,需 un-scoped) */
.horizontal-menu-popper {
.el-menu-item:hover,
.el-sub-menu__title:hover {
background-color: var(--el-color-primary-light-9) !important;
border-radius: 6px;
}
}
/* 暗色模式:hover 背景使用半透明主题色 */
.dark {
.horizontal-menu-popper {
.el-menu-item:hover,
.el-sub-menu__title:hover {
background-color: color-mix(in srgb, var(--el-color-primary) 15%, transparent) !important;
}
}
}
</style>
@@ -15,7 +15,6 @@
:key="child.path"
:item="child"
:theme="theme"
:is-mobile="isMobile"
:level="level + 1"
@close="closeMenu"
/>
@@ -54,7 +53,6 @@ defineOptions({ name: "FaHorizontalSubmenu" });
interface Props {
item: AppRouteRecord;
theme?: Record<string, any>;
isMobile?: boolean;
level?: number;
}
@@ -248,6 +248,15 @@ onMounted(initScrollState);
background-color: var(--theme-color);
}
.menu-item:hover {
background-color: var(--el-color-primary-light-9);
border-radius: 6px;
}
.dark .menu-item:hover {
background-color: color-mix(in srgb, var(--el-color-primary) 15%, transparent);
}
@media (width <= 1440px) {
:deep(.scrollbar-wrapper) {
margin: 0 45px;
@@ -100,12 +100,7 @@
:show-timeout="50"
:hide-timeout="50"
>
<SidebarSubmenu
:list="menuList"
:isMobile="isMobileMode"
:theme="getMenuTheme"
@close="handleMenuClose"
/>
<SidebarSubmenu :list="menuList" :theme="getMenuTheme" @close="handleMenuClose" />
</ElMenu>
</ElScrollbar>
@@ -137,10 +132,10 @@ import { isIframe, handleMenuJump } from "@utils";
import SidebarSubmenu from "./widgets/FaSidebarSubmenu.vue";
import { useCommon } from "@/hooks/core/useCommon";
import { useWindowSize, useTimeoutFn } from "@vueuse/core";
import { MOBILE_BREAKPOINT } from "@utils/constants";
defineOptions({ name: "FaSidebarMenu" });
const MOBILE_BREAKPOINT = 800;
const ANIMATION_DELAY = 350;
const MENU_CLOSE_WIDTH = MenuWidth.CLOSE;
@@ -173,7 +168,6 @@ const {
// 组件内部状态
const defaultOpenedMenus = ref<string[]>([]);
const isMobileMode = ref(false);
const showMobileModal = ref(false);
// 使用 VueUse 的窗口尺寸监听
@@ -379,7 +373,7 @@ watch(menuOpen, (isMenuOpen: boolean) => {
:deep(.el-scrollbar__thumb) {
right: -2px;
background-color: #ccc;
background-color: var(--fa-gray-400);
border-radius: 2px;
}
@@ -588,7 +582,7 @@ watch(menuOpen, (isMenuOpen: boolean) => {
display: block;
width: 100%;
height: 100vh;
background: rgba($color: #000, $alpha: 50%);
background: var(--fa-overlay-heavy);
transition: opacity 0.2s ease-in-out;
}
}
@@ -602,14 +596,14 @@ watch(menuOpen, (isMenuOpen: boolean) => {
.dark {
.layout-sidebar {
border-right: 1px solid rgb(255 255 255 / 13%);
border-right: 1px solid var(--fa-dark-border-subtle);
:deep(.el-scrollbar__thumb) {
background-color: #777;
background-color: var(--fa-scrollbar-thumb);
}
.dual-menu-left {
border-right: 1px solid rgb(255 255 255 / 9%) !important;
border-right: 1px solid var(--fa-dark-border-subtle) !important;
}
}
}
@@ -622,7 +616,7 @@ watch(menuOpen, (isMenuOpen: boolean) => {
$menu-height: 42px;
$menu-icon-size: 20px;
$menu-font-size: 14px;
$hover-bg-color: var(--fa-gray-200);
$hover-bg-color: var(--el-color-primary-light-9);
$popup-menu-height: 40px;
$popup-menu-padding: 8px;
$popup-menu-margin: 5px;
@@ -783,8 +777,8 @@ $popup-menu-radius: 6px;
/* ---------------------- Dark theme menu ---------------------- */
.el-menu-dark {
@include theme-menu-base;
@include menu-active(#fff, #27282d, #fff);
@include menu-hover(#0f1015);
@include menu-active(var(--fa-gray-900), var(--fa-gray-300), var(--fa-gray-900));
@include menu-hover(var(--fa-gray-200));
.el-sub-menu__icon-arrow {
color: var(--fa-gray-400);
@@ -843,14 +837,18 @@ $popup-menu-radius: 6px;
/* 菜单折叠 hover 弹窗样式(浅色主题) */
.el-menu--vertical,
.el-menu--popup-container {
@include popup-menu-base(var(--fa-gray-200), var(--fa-gray-900), var(--fa-gray-200));
@include popup-menu-base(var(--el-color-primary-light-9), var(--fa-gray-900), var(--fa-gray-200));
}
/* 暗黑模式菜单样式 */
.dark {
.el-menu--vertical,
.el-menu--popup-container {
@include popup-menu-base(var(--fa-gray-200), var(--fa-gray-900), #292a2e);
@include popup-menu-base(
var(--el-color-primary-light-9),
var(--fa-gray-900),
var(--fa-gray-300)
);
}
.layout-sidebar {
@@ -860,6 +858,12 @@ $popup-menu-radius: 6px;
color: var(--fa-gray-800) !important;
}
/* 暗色模式:悬浮使用半透明主题色 */
.el-menu-item:not(.is-active):hover,
.el-sub-menu__title:not(.is-active):hover {
background-color: color-mix(in srgb, var(--el-color-primary) 15%, transparent) !important;
}
/* 选中的文字颜色跟图标颜色 */
.el-menu-item.is-active {
span,
@@ -870,7 +874,7 @@ $popup-menu-radius: 6px;
/* 右侧箭头颜色 */
.el-sub-menu__icon-arrow {
color: #fff;
color: var(--fa-gray-900);
}
}
}
@@ -17,7 +17,6 @@
<FaSidebarSubmenu
:list="item.children"
:is-mobile="isMobile"
:level="level + 1"
:theme="theme"
@close="closeMenu"
@@ -75,8 +74,6 @@ interface Props {
list?: AppRouteRecord[];
/** 主题配置 */
theme?: MenuTheme;
/** 是否为移动端模式 */
isMobile?: boolean;
/** 菜单层级 */
level?: number;
}
@@ -90,7 +87,6 @@ const props = withDefaults(defineProps<Props>(), {
title: "",
list: () => [],
theme: () => ({}),
isMobile: false,
level: 0,
});
@@ -1,7 +1,7 @@
<!-- 通知组件 -->
<template>
<div
class="absolute top-14.5 right-5 w-90 h-125 overflow-hidden transition-all duration-300 origin-top will-change-[top,left] max-[640px]:top-[65px] max-[640px]:right-0 max-[640px]:w-full max-[640px]:h-[80vh] fa-card-sm shadow-xl!"
class="fa-notification-panel fa-card-sm !shadow-xl flex flex-col"
:style="{
transform: show ? 'scaleY(1)' : 'scaleY(0.9)',
opacity: show ? 1 : 0,
@@ -9,20 +9,18 @@
v-show="visible"
@click.stop
>
<div class="flex items-center justify-between px-3.5 mt-3.5">
<div class="flex-cb px-3.5 mt-3.5 flex-shrink-0">
<span class="text-base font-medium text-g-800">{{ $t("notice.title") }}</span>
<span
class="text-xs text-g-800 px-1.5 py-1 cursor-pointer select-none rounded hover:bg-g-200"
>
<span class="text-xs text-g-800 px-1.5 py-1 c-p select-none rounded hover:bg-g-200">
{{ $t("notice.btnRead") }}
</span>
</div>
<ul class="box-border flex items-end w-full h-12.5 px-3.5 border-b border-(--default-border)">
<ul class="box-border flex items-end w-full h-12.5 px-3.5 border-b-d flex-shrink-0">
<li
v-for="(item, index) in barList"
:key="index"
class="h-12 leading-12 mr-5 overflow-hidden text-[13px] text-g-700 cursor-pointer select-none"
class="h-12 leading-12 mr-5 overflow-hidden text-[13px] text-g-700 c-p select-none"
:class="{ 'bar-active': barActiveIndex === index }"
@click="changeBar(index)"
>
@@ -30,79 +28,73 @@
</li>
</ul>
<div class="w-full h-[calc(100%-95px)]">
<ElScrollbar class="h-[calc(100%-60px)]">
<!-- 通知 -->
<ul v-show="barActiveIndex === 0">
<li
v-for="(item, index) in noticeList"
:key="index"
class="box-border flex items-center px-3.5 py-3.5 cursor-pointer last:border-b-0 hover:bg-g-200/60"
>
<div
class="size-9 leading-9 text-center rounded-lg flex items-center justify-center"
:class="[getNoticeStyle(item.type).iconClass]"
>
<FaSvgIcon class="text-lg bg-transparent!" :icon="getNoticeStyle(item.type).icon" />
</div>
<div class="w-[calc(100%-45px)] ml-3.5">
<h4 class="text-sm font-normal leading-5.5 text-g-900">{{ item.title }}</h4>
<p class="mt-1.5 text-xs text-g-500">{{ item.time }}</p>
</div>
</li>
</ul>
<!-- 消息 -->
<ul v-show="barActiveIndex === 1">
<li
v-for="(item, index) in msgList"
:key="index"
class="box-border flex items-center px-3.5 py-3.5 cursor-pointer last:border-b-0 hover:bg-g-200/60"
>
<div
class="size-9 leading-9 text-center rounded-lg flex items-center justify-center bg-info/12 text-info"
>
<FaSvgIcon class="text-lg bg-transparent!" icon="ri:message-3-line" />
</div>
<div class="w-[calc(100%-45px)] ml-3.5">
<h4 class="text-sm font-normal leading-5.5 text-g-900">{{ item.title }}</h4>
<p class="mt-1.5 text-xs text-g-500">{{ item.time }}</p>
</div>
</li>
</ul>
<!-- 待办 -->
<ul v-show="barActiveIndex === 2">
<li
v-for="(item, index) in pendingList"
:key="index"
class="box-border px-5 py-3.5 last:border-b-0"
>
<h4>{{ item.title }}</h4>
<p class="text-xs text-g-500">{{ item.time }}</p>
</li>
</ul>
<!-- 空状态 -->
<div
v-show="currentTabIsEmpty"
class="relative top-25 h-full text-g-500 text-center bg-transparent!"
<ElScrollbar class="flex-1 min-h-0 overflow-y-scroll scrollbar-thin">
<!-- 通知 -->
<ul v-show="barActiveIndex === 0">
<li
v-for="(item, index) in noticeList"
:key="index"
class="box-border flex-c px-3.5 py-3.5 c-p last:border-b-0 hover:bg-g-200/60"
>
<FaSvgIcon icon="system-uicons:inbox" class="text-5xl" />
<p class="mt-3.5 text-xs bg-transparent!">
{{ $t("notice.text[0]") }}{{ barList[barActiveIndex]?.name }}
</p>
</div>
</ElScrollbar>
<div
class="size-9 leading-9 text-center rounded-lg flex-cc"
:class="[getNoticeStyle(item.type).iconClass]"
>
<FaSvgIcon class="text-lg bg-transparent!" :icon="getNoticeStyle(item.type).icon" />
</div>
<div class="w-[calc(100%-45px)] ml-3.5">
<h4 class="text-sm font-normal leading-5.5 text-g-900">{{ item.title }}</h4>
<p class="mt-1.5 text-xs text-g-500">{{ item.time }}</p>
</div>
</li>
</ul>
<div class="relative box-border w-full px-3.5">
<ElButton class="w-full mt-3" @click="handleViewAll" v-ripple>
{{ $t("notice.viewAll") }}
</ElButton>
<!-- 消息 -->
<ul v-show="barActiveIndex === 1">
<li
v-for="(item, index) in msgList"
:key="index"
class="box-border flex-c px-3.5 py-3.5 c-p last:border-b-0 hover:bg-g-200/60"
>
<div class="w-9 h-9">
<FaSvgIcon class="w-full h-full rounded-lg" icon="ri:message-3-line" />
</div>
<div class="w-[calc(100%-45px)] ml-3.5">
<h4 class="text-xs font-normal leading-5.5">{{ item.title }}</h4>
<p class="mt-1.5 text-xs text-g-500">{{ item.time }}</p>
</div>
</li>
</ul>
<!-- 待办 -->
<ul v-show="barActiveIndex === 2">
<li
v-for="(item, index) in pendingList"
:key="index"
class="box-border px-5 py-3.5 last:border-b-0"
>
<h4>{{ item.title }}</h4>
<p class="text-xs text-g-500">{{ item.time }}</p>
</li>
</ul>
<!-- 空状态 -->
<div
v-show="currentTabIsEmpty"
class="h-full text-g-500 text-center !bg-transparent flex flex-col items-center justify-center mt-12"
>
<FaSvgIcon icon="system-uicons:inbox" class="text-5xl" />
<p class="mt-3.5 text-xs !bg-transparent">
{{ $t("notice.text[0]") }}{{ barList[barActiveIndex]?.name }}
</p>
</div>
</div>
</ElScrollbar>
<div class="h-25"></div>
<div class="box-border w-full px-3.5 pt-2 pb-3.5 flex-shrink-0">
<ElButton class="w-full" @click="handleViewAll" v-ripple>
{{ $t("notice.viewAll") }}
</ElButton>
</div>
</div>
</template>
@@ -261,7 +253,7 @@ const useNotificationStyles = () => {
iconClass: "bg-success/12 text-success",
},
collection: {
icon: "ri:hefa-3-line",
icon: "ri:heart-3-line",
iconClass: "bg-danger/12 text-danger",
},
user: {
@@ -399,8 +391,43 @@ watch(
</script>
<style scoped>
@reference '@styles/tailwind.css';
.fa-notification-panel {
@apply absolute
top-14.5
right-5
w-90
h-125
overflow-hidden
transition-all
duration-300
origin-top
will-change-[top,left]
max-[640px]:top-[65px]
max-[640px]:right-0
max-[640px]:w-full
max-[640px]:h-[80vh];
}
.fa-notification-panel.fa-notification-panel {
border-radius: calc(var(--custom-radius) + 2px) !important;
}
.bar-active {
color: var(--theme-color) !important;
color: var(--theme-color) important;
border-bottom: 2px solid var(--theme-color);
}
.scrollbar-thin::-webkit-scrollbar {
width: 5px !important;
}
.dark .scrollbar-thin::-webkit-scrollbar-track {
background-color: var(--default-box-color);
}
.dark .scrollbar-thin::-webkit-scrollbar-thumb {
background-color: var(--fa-scrollbar-thumb-dark) !important;
}
</style>
@@ -527,12 +527,22 @@ onUnmounted(() => {
width: 70px;
height: 70px;
border-radius: 50%;
@media (width <= 640px) {
width: 50px;
height: 50px;
}
}
&-name {
margin: 10px 0;
font-size: 14px;
color: var(--top-header-text-color);
@media (width <= 640px) {
margin: 8px 0;
font-size: 12px;
}
}
}
}
@@ -622,7 +632,7 @@ onUnmounted(() => {
justify-content: center;
width: 100%;
height: 100%;
background-color: rgb(0 0 0 / 50%);
background-color: var(--fa-overlay-heavy);
backdrop-filter: blur(8px);
}
@@ -642,12 +652,22 @@ onUnmounted(() => {
width: 70px;
height: 70px;
border-radius: 50%;
@media (width <= 640px) {
width: 50px;
height: 50px;
}
}
.username {
margin: 0.625rem 0;
font-size: 0.875rem;
color: var(--el-text-color-primary);
@media (width <= 640px) {
margin: 0.5rem 0;
font-size: 0.75rem;
}
}
.password-input {
@@ -1,7 +1,8 @@
import { ref, computed, watch } from "vue";
import { useSettingsStore } from "@stores";
import { storeToRefs } from "pinia";
import { useBreakpoints } from "@vueuse/core";
import { useWindowSize } from "@vueuse/core";
import { MOBILE_BREAKPOINT } from "@utils/constants";
import AppConfig from "@/config";
import { SystemThemeEnum, MenuTypeEnum } from "@/enums/appEnum";
import { mittBus, StorageConfig } from "@utils";
@@ -26,9 +27,9 @@ export function useSettingsPanel() {
// 响应式状态
const showDrawer = ref(false);
// 使用 VueUse breakpoints 优化性能
const breakpoints = useBreakpoints({ tablet: 1000 });
const isMobile = breakpoints.smaller("tablet");
// 响应式布局
const { width } = useWindowSize();
const isMobile = computed(() => width.value < MOBILE_BREAKPOINT);
// 记录窗口宽度变化前的菜单类型
const getStoredDesktopMenuType = (): MenuTypeEnum | undefined => {
@@ -67,7 +67,7 @@ onUnmounted(() => {
.el-drawer {
// 背景滤镜效果
background: rgba($color: #fff, $alpha: 50%) !important;
background: var(--fa-drawer-bg) !important;
box-shadow: 0 0 30px rgb(0 0 0 / 10%) !important;
@include backdropBlur();
@@ -129,7 +129,7 @@ onUnmounted(() => {
.dark {
.setting-modal {
.el-drawer {
background: rgba($color: #000, $alpha: 50%) !important;
background: var(--fa-drawer-bg) !important;
.setting-item {
.box {
@@ -760,7 +760,7 @@ watch(
}
.dark .worktab-toolbar-end::before {
background: rgb(255 255 255 / 12%);
background: var(--fa-dark-border);
}
/* 工具区内部按钮分割线:拉满到标签栏高度 */
@@ -785,7 +785,7 @@ watch(
}
.dark .worktab-toolbar-end .worktab-bar-cell + .worktab-bar-cell::before {
background: rgb(255 255 255 / 12%);
background: var(--fa-dark-border);
}
/*
@@ -837,20 +837,20 @@ watch(
}
.dark .worktab-tags-bar .worktab-bar-btn {
color: rgb(255 255 255 / 72%);
color: var(--fa-dark-text);
}
.dark .worktab-tags-bar .worktab-bar-cell--sep-r {
border-right-color: rgb(255 255 255 / 12%);
border-right-color: var(--fa-dark-border);
}
.dark .worktab-tags-bar .worktab-bar-btn:hover {
color: var(--el-color-primary);
background-color: rgb(255 255 255 / 8%);
background-color: var(--fa-dark-hover);
}
.dark .worktab-tags-bar .worktab-bar-btn:active {
background-color: rgb(255 255 255 / 12%);
background-color: var(--fa-dark-active);
}
.dark .worktab-tags-bar .worktab-bar-btn:focus-visible {
@@ -1016,7 +1016,7 @@ html:not(.dark) .worktab-tags-shell--google {
}
.dark .worktab-tags-shell--google .worktab-google-divider {
background: rgb(255 255 255 / 14%);
background: var(--fa-dark-border-lighter);
}
/* 悬停当前标签:隐藏与本标签相邻的两条竖线(自身左缘 + 下一标签左缘) */
@@ -1039,27 +1039,27 @@ html:not(.dark) .worktab-tags-shell--google {
/* 固定标签图钉(pushpin):常用黄色,与路由菜单图标区分 */
.worktab-pin {
color: #ca8a04 !important;
color: var(--fa-tab-pin) !important;
}
.dark .worktab-pin {
color: #fbbf24 !important;
color: var(--fa-tab-pin) !important;
}
.worktab-tags-shell--google .worktab-pin {
color: #ca8a04 !important;
color: var(--fa-tab-pin) !important;
}
.dark .worktab-tags-shell--google .worktab-pin {
color: #fbbf24 !important;
color: var(--fa-tab-pin) !important;
}
.group:hover .worktab-pin {
color: #a16207 !important;
color: var(--fa-tab-pin-group-hover) !important;
}
.dark .group:hover .worktab-pin {
color: #fcd34d !important;
color: var(--fa-tab-pin-group-hover) !important;
}
.google-tab::before,
@@ -128,7 +128,7 @@ interface Props {
const props = withDefaults(defineProps<Props>(), {
column: 4,
border: true,
size: "default",
size: undefined,
labelWidth: undefined,
items: () => [],
data: null,
@@ -1,378 +0,0 @@
import { ref, watch, nextTick, computed, h } from "vue";
import type { TableInstance, CheckboxValueType } from "element-plus";
import FaMenuRouteIcon from "@/components/others/fa-menu-route-icon/index.vue";
// ---- 类型常量 ----
const DIR_TYPE = 1;
const MENU_TYPE = 2;
const BTN_TYPE = 3;
function isDirOrMenu(type: number | undefined): boolean {
return type === DIR_TYPE || type === MENU_TYPE;
}
function isPermission(type: number | undefined): boolean {
return type != null && type >= BTN_TYPE;
}
interface MenuTreeTableProps {
menuTree: any[];
checkedIds?: number[];
loading?: boolean;
}
/**
* 菜单树表格业务逻辑
* 负责:搜索过滤、级联勾选、父子联动、展开/收起、初始化选中、对外方法
*/
export function useMenuTreeTable(rawProps: MenuTreeTableProps) {
// 直接使用父组件传入的 props(vue 3 中子组件 props 本身就是 reactive proxy)
// 不要再 reactive() 一次,否则会丢失响应式(只能拿到首次快照)
const props = rawProps;
// ---- 状态 ----
const filterText = ref("");
const isExpanded = ref(true);
const parentChildLinked = ref(true);
const isHandlingCascade = ref(false);
const permTableRef = ref<TableInstance>();
const tableData = ref<any[]>([]);
const checkedBtns = ref<Record<number, number[]>>({});
const selectedMenuIds = ref<Set<number>>(new Set());
// ---- 搜索过滤后的菜单树 ----
const filteredMenuTree = computed(() =>
filterText.value ? filterMenuTree(props.menuTree, filterText.value) : props.menuTree
);
function filterMenuTree(nodes: any[], text: string): any[] {
if (!text) return nodes;
const lower = text.toLowerCase();
function walk(n: any): any | null {
const nameMatch = (n.name || "").toLowerCase().includes(lower);
const filteredChildren = n.children?.map(walk).filter(Boolean) ?? [];
if (nameMatch || filteredChildren.length > 0) {
return { ...n, children: filteredChildren.length > 0 ? filteredChildren : n.children };
}
return null;
}
return nodes.map(walk).filter(Boolean) as any[];
}
// ---- 工具函数 ----
function walkTree(nodes: any[], fn: (node: any) => void) {
for (const node of nodes) {
fn(node);
if (node.children) walkTree(node.children, fn);
}
}
function findRawNode(id: number, nodes: any[]): any | undefined {
for (const node of nodes) {
if (node.id === id) return node;
if (node.children) {
const found = findRawNode(id, node.children);
if (found) return found;
}
}
return undefined;
}
function findTableNode(id: number, nodes: any[]): any | undefined {
for (const node of nodes) {
if (node.id === id) return node;
if (node.children) {
const found = findTableNode(id, node.children);
if (found) return found;
}
}
return undefined;
}
function findParentInRawTree(menuId: number, nodes: any[]): any | null {
for (const n of nodes) {
if (n.children?.some((c: any) => c.id === menuId)) return n;
if (n.children) {
const found = findParentInRawTree(menuId, n.children);
if (found) return found;
}
}
return null;
}
function formatMenuName(row: any) {
const name = row.name || "";
if (row.icon) {
return h("span", { class: "inline-flex items-center gap-1.5" }, [
h(FaMenuRouteIcon, { icon: row.icon, style: { verticalAlign: "-0.15em" } }),
name,
]);
}
return name;
}
function getMenuBtns(row: any): any[] {
const fullNode = findRawNode(row.id, props.menuTree);
if (!fullNode?.children) return [];
return fullNode.children.filter((c: any) => isPermission(c.type));
}
function hasAnySelectedDescendant(node: any): boolean {
if (checkedBtns.value[node.id!]?.length ?? 0 > 0) return true;
const tableNode = findTableNode(node.id!, tableData.value);
if (tableNode?.children) {
for (const child of tableNode.children) {
if (isDirOrMenu(child.type) && hasAnySelectedDescendant(child)) return true;
}
}
return false;
}
function findAncestors(nodeId: number): any[] {
function collectAncestorIds(nodes: any[], target: number): number[] {
for (const n of nodes) {
if (n.children?.some((c: any) => c.id === target)) {
return [n.id!, ...collectAncestorIds(props.menuTree, n.id!)];
}
if (n.children) {
const found = collectAncestorIds(n.children, target);
if (found.length > 0) return found;
}
}
return [];
}
return collectAncestorIds(props.menuTree, nodeId)
.map((id) => findTableNode(id, tableData.value))
.filter(Boolean) as any[];
}
function filterTableData(nodes: any[]): any[] {
return nodes
.filter((n) => isDirOrMenu(n.type))
.map((n) => ({
...n,
children: n.children ? filterTableData(n.children) : undefined,
}));
}
// ---- 级联 ----
function collectBtnState(node: any) {
const ids = getMenuBtns(node).map((b: any) => b.id!);
if (ids.length) checkedBtns.value[node.id!] = ids;
}
function cascadeSelect(node: any) {
permTableRef.value?.toggleRowSelection(node, true);
collectBtnState(node);
const tableNode = findTableNode(node.id, tableData.value);
if (tableNode?.children) {
for (const child of tableNode.children) {
if (isDirOrMenu(child.type)) cascadeSelect(child);
}
}
}
function cascadeDeselect(node: any) {
permTableRef.value?.toggleRowSelection(node, false);
checkedBtns.value[node.id!] = [];
const tableNode = findTableNode(node.id, tableData.value);
if (tableNode?.children) {
for (const child of tableNode.children) {
if (isDirOrMenu(child.type)) cascadeDeselect(child);
}
}
}
function selectAncestors(node: any) {
findAncestors(node.id!).forEach((a) => permTableRef.value?.toggleRowSelection(a, true));
}
function deselectAncestorsIfNeeded(node: any) {
findAncestors(node.id!).forEach((a) => {
if (!hasAnySelectedDescendant(a)) permTableRef.value?.toggleRowSelection(a, false);
});
}
function withCascadeGuard(fn: () => void) {
isHandlingCascade.value = true;
try {
fn();
} finally {
isHandlingCascade.value = false;
}
}
// ---- Table 事件 ----
function onSelectionChange() {
const store = (permTableRef.value as any)?.store;
const selection = store?.states?.selection?.value || [];
const newSelected = new Set<number>();
selection.forEach((row: any) => {
if (isDirOrMenu(row.type)) newSelected.add(row.id!);
});
selectedMenuIds.value = newSelected;
if (newSelected.size === 0) checkedBtns.value = {};
}
function onSelectAll() {
const store = (permTableRef.value as any)?.store;
const selection = store?.states?.selection?.value || [];
withCascadeGuard(() => {
if (!selection.length) {
checkedBtns.value = {};
selectedMenuIds.value.clear();
return;
}
for (const row of selection) {
if (isDirOrMenu(row.type)) {
selectedMenuIds.value.add(row.id!);
if (parentChildLinked.value) collectBtnState(row);
}
}
});
}
function onSelect(selection: any[], row: any) {
if (isHandlingCascade.value || !parentChildLinked.value) return;
const tableRow = findTableNode(row.id, tableData.value);
if (!tableRow) return;
const isSelected = selection.some((r) => r.id === tableRow.id);
withCascadeGuard(() => {
if (isSelected) {
selectAncestors(tableRow);
cascadeSelect(tableRow);
} else {
cascadeDeselect(tableRow);
deselectAncestorsIfNeeded(tableRow);
}
});
}
function onBtnChange(row: any, val: CheckboxValueType[]) {
checkedBtns.value[row.id!] = val.map((v) => Number(v));
if (!parentChildLinked.value) return;
withCascadeGuard(() => {
if (val.length > 0) {
permTableRef.value?.toggleRowSelection(row, true);
selectAncestors(row);
} else if (!hasAnySelectedDescendant(row)) {
permTableRef.value?.toggleRowSelection(row, false);
deselectAncestorsIfNeeded(row);
}
});
}
// ---- 展开/收起 ----
function setAllRowsExpanded(expanded: boolean) {
nextTick(() => {
const el = permTableRef.value;
if (!el || !tableData.value.length) return;
walkTree(tableData.value, (row) => {
if (row.children?.length) el.toggleRowExpansion(row, expanded);
});
});
}
function toggleExpand() {
isExpanded.value = !isExpanded.value;
setAllRowsExpanded(isExpanded.value);
}
// ---- 初始化 ----
function checkParentChildLinked(ids: number[], nodes: any[]): boolean {
if (!ids.length || !nodes.length) return true;
const idMap = new Map<number, any>();
walkTree(nodes, (n) => idMap.set(n.id!, n));
for (const id of ids) {
const node = idMap.get(id);
if (!node) continue;
if (node.children?.length && node.children.some((c: any) => !ids.includes(c.id!)))
return false;
const parent = findParentInRawTree(id, nodes);
if (parent && !ids.includes(parent.id!)) return false;
}
return true;
}
async function initSelection(ids: number[]) {
await nextTick();
const btnsState: Record<number, number[]> = {};
const rowsToSelect: any[] = [];
walkTree(tableData.value, (node) => {
if (isDirOrMenu(node.type) && ids.includes(node.id!)) {
rowsToSelect.push(node);
}
});
walkTree(props.menuTree, (node) => {
if (!isPermission(node.type) || !ids.includes(node.id!)) return;
const parent = findParentInRawTree(node.id!, props.menuTree);
if (parent) (btnsState[parent.id!] ??= []).push(node.id!);
});
checkedBtns.value = btnsState;
withCascadeGuard(() => {
const store = (permTableRef.value as any)?.store;
if (!store) return;
store.clearSelection();
rowsToSelect.forEach((row) => store.toggleRowSelection(row, true, false));
});
}
// ---- 对外方法 ----
function getCheckedIds(): number[] {
const allIds = new Set<number>();
selectedMenuIds.value.forEach((id) => allIds.add(id));
Object.values(checkedBtns.value).forEach((btnIds) => btnIds.forEach((id) => allIds.add(id)));
return [...allIds];
}
function refresh() {
filterText.value = "";
const tree = props.menuTree;
const ids = props.checkedIds ?? [];
parentChildLinked.value = checkParentChildLinked(ids, tree);
tableData.value = filterTableData(tree);
nextTick(() => initSelection(ids));
}
// ---- 数据变化监听 ----
watch(
() => [props.menuTree, props.checkedIds] as const,
() => refresh(),
{ immediate: true, deep: true }
);
watch(filterText, () => {
const tree = filteredMenuTree.value;
tableData.value = filterTableData(tree);
nextTick(() => initSelection(props.checkedIds ?? []));
if (filterText.value) setAllRowsExpanded(true);
});
return {
// 状态
filterText,
isExpanded,
parentChildLinked,
permTableRef,
tableData,
checkedBtns,
// 工具
formatMenuName,
getMenuBtns,
// 事件
onSelectionChange,
onSelectAll,
onSelect,
onBtnChange,
toggleExpand,
// 对外方法
getCheckedIds,
refresh,
};
}
@@ -1,6 +1,6 @@
<!-- 菜单权限树表:搜索 + 展开/收起 + 父子联动 + 树形勾选 -->
<template>
<div class="flex flex-col h-full overflow-hidden">
<div class="flex flex-col h-full">
<!-- 搜索栏 + 操作按钮 -->
<div class="mb-3 flex items-center gap-3 shrink-0">
<ElInput
v-model="filterText"
@@ -10,93 +10,195 @@
:prefix-icon="Search"
size="small"
/>
<ElButton type="primary" size="small" plain @click="toggleExpand">
<ElButton type="primary" size="small" plain @click="toggleExpandAll">
<template #icon><SwitchIcon /></template>
{{ isExpanded ? "收起" : "展开" }}
</ElButton>
<ElCheckbox v-model="parentChildLinked"> 父子联动 </ElCheckbox>
<ElCheckbox v-model="parentChildLinked">父子联动</ElCheckbox>
</div>
<ElTable
ref="permTableRef"
v-loading="loading"
:data="tableData"
row-key="id"
:tree-props="{ children: 'children', hasChildren: 'hasChildren' }"
:default-expand-all="true"
class="flex-1 min-h-0"
@selection-change="onSelectionChange"
@select="onSelect"
@select-all="onSelectAll"
>
<ElTableColumn type="selection" width="48" />
<ElTableColumn
prop="name"
label="菜单名称"
width="220"
show-overflow-tooltip
:formatter="formatMenuName"
/>
<ElTableColumn label="功能权限">
<template #default="{ row }">
<ElCheckboxGroup
v-model="checkedBtns[row.id]"
class="flex flex-wrap gap-x-3"
@change="(val: CheckboxValueType[]) => onBtnChange(row, val)"
>
<ElCheckbox
v-for="btn in getMenuBtns(row)"
:key="btn.id"
:value="btn.id"
:label="btn.name"
/>
</ElCheckboxGroup>
<!-- 菜单树 -->
<div class="flex-1 overflow-auto" v-loading="loading">
<ElTree
ref="treeRef"
node-key="id"
:data="menuTree"
show-checkbox
:check-strictly="!parentChildLinked"
:default-expand-all="isExpanded"
:filter-node-method="filterNode"
:props="{ children: 'children', label: 'name' }"
>
<template #default="{ data }">
<div class="menu-node flex items-center gap-2">
<FaMenuRouteIcon v-if="data.icon" :icon="data.icon" />
<span class="node-name" :class="{ 'is-dir': data.type === 1 }">
{{ data.name }}
</span>
<ElTag :type="nodeMeta(data)!.type" size="small" effect="plain" round>
{{ nodeMeta(data)!.label }}
</ElTag>
</div>
</template>
</ElTableColumn>
</ElTable>
</ElTree>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch, nextTick } from "vue";
import { Search, Switch as SwitchIcon } from "@element-plus/icons-vue";
import type { CheckboxValueType } from "element-plus";
import { useMenuTreeTable } from "./composables/useMenuTreeTable";
import FaMenuRouteIcon from "@/components/others/fa-menu-route-icon/index.vue";
defineOptions({ name: "FaMenuTreeTable" });
// ==================== 类型 ====================
interface MenuNode {
id?: number;
type?: number; // 1=目录 2=菜单 3=按钮 4=链接
name?: string;
icon?: string;
parent_id?: number;
children?: MenuNode[];
}
// el-tree 内部节点结构(仅用到部分字段)
interface TreeNode {
data: MenuNode;
checked: boolean;
indeterminate: boolean;
expanded?: boolean;
childNodes: TreeNode[];
parent: TreeNode | null;
}
interface Props {
menuTree: any[];
menuTree: MenuNode[];
checkedIds?: number[];
loading?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
checkedIds: () => [],
loading: false,
});
const {
filterText,
isExpanded,
parentChildLinked,
permTableRef,
tableData,
checkedBtns,
formatMenuName,
getMenuBtns,
onSelectionChange,
onSelectAll,
onSelect,
onBtnChange,
toggleExpand,
getCheckedIds,
refresh,
} = useMenuTreeTable(props);
// 节点类型常量(1=目录 2=菜单 3=按钮 4=链接)
const isLeaf = (t?: number) => t === 3 || t === 4; // 按钮 / 链接
defineExpose({ getCheckedIds, refresh });
</script>
// 节点类型 → 标签样式 / 文案
type TagType = "primary" | "success" | "warning" | "danger" | "info";
const NODE_META: Record<number, { type: TagType; label: string }> = {
1: { type: "warning", label: "目录" },
2: { type: "primary", label: "菜单" },
3: { type: "success", label: "按钮" },
4: { type: "danger", label: "链接" },
};
const nodeMeta = (n: MenuNode) => NODE_META[n.type ?? 2] ?? NODE_META[2];
<style scoped lang="scss">
.menu-tree-search-input {
width: 320px;
// ==================== 状态 ====================
const treeRef = ref<any>(null);
const filterText = ref("");
const isExpanded = ref(true);
const parentChildLinked = ref(true);
// 工具:安全获取 el-tree 内部 nodesMap
const getNodesMap = () => treeRef.value?.store?.nodesMap as Record<number, TreeNode> | undefined;
// ==================== 搜索 / 展开 ====================
function filterNode(value: string, data: any) {
if (!value) return true;
return (data.name ?? "").toLowerCase().includes(value.toLowerCase());
}
</style>
function setAllExpanded(expanded: boolean) {
nextTick(() => {
const nodesMap = getNodesMap();
if (!nodesMap) return;
for (const node of Object.values(nodesMap)) {
node.expanded = expanded;
}
});
}
function toggleExpandAll() {
isExpanded.value = !isExpanded.value;
setAllExpanded(isExpanded.value);
}
// ==================== 父级状态计算 ====================
// 单次遍历子节点:统计 fully checked + 是否存在 indeterminate
function recomputeNode(p: TreeNode) {
let fully = 0;
let hasIndeterminate = false;
for (const c of p.childNodes) {
if (c.checked) fully++;
else if (c.indeterminate) hasIndeterminate = true;
}
const total = p.childNodes.length;
p.checked = fully === total;
p.indeterminate = (fully > 0 || hasIndeterminate) && !p.checked;
}
// ==================== 初始化 ====================
// 回显策略:只勾叶子(按钮/链接),父级状态自动向上传播半选
function initFromProps() {
nextTick(() => {
const tree = treeRef.value;
const nodesMap = getNodesMap();
if (!tree || !nodesMap) return;
// 1. 清空
for (const node of Object.values(nodesMap)) {
node.checked = false;
node.indeterminate = false;
}
// 2. 勾叶子 + 收集受影响父级(去重,每个父级只重算一次)
const affected = new Set<TreeNode>();
for (const id of props.checkedIds ?? []) {
const node = tree.getNode(id) as TreeNode | null;
if (!node || !isLeaf(node.data.type)) continue;
node.checked = true;
for (let p = node.parent; p; p = p.parent) affected.add(p);
}
// 3. 统一重算
for (const p of affected) recomputeNode(p);
});
}
// ==================== 对外 API ====================
function getCheckedIds(): number[] {
const tree = treeRef.value;
if (!tree) return [];
const ids = new Set<number>();
// 完全选中的菜单 / 按钮 / 链接
for (const n of (tree.getCheckedNodes() ?? []) as MenuNode[]) {
if (n.id != null && n.type !== 1) ids.add(n.id);
}
// 半选父级(菜单 + 目录)—— 作为父级路径传后端
for (const n of (tree.getHalfCheckedNodes() ?? []) as MenuNode[]) {
if (n.id != null) ids.add(n.id);
}
return [...ids];
}
defineExpose({ getCheckedIds, refresh: initFromProps });
// ==================== 监听 ====================
watch(
() => [props.menuTree, props.checkedIds] as const,
() => initFromProps(),
{ immediate: true, deep: true }
);
// 切换父子联动时重新初始化:check-strictly 改变需要重置半选状态
watch(parentChildLinked, () => initFromProps());
watch(filterText, (val) => {
treeRef.value?.filter(val);
if (val) {
isExpanded.value = true;
setAllExpanded(true);
}
});
</script>
@@ -92,7 +92,57 @@ function handleCurrentChange(val: number) {
<style lang="scss" scoped>
.pagination {
display: flex;
flex-shrink: 0;
justify-content: center;
// padding: 12px 0;
/* 自定义分页按钮样式(透明背景 + 主题色边框) */
:deep(.el-pagination) {
.btn-prev,
.btn-next {
background-color: transparent;
border: 1px solid var(--fa-gray-300);
transition: border-color 0.15s;
&:hover:not(.is-disabled) {
color: var(--theme-color);
border-color: var(--theme-color);
}
}
li {
box-sizing: border-box;
font-weight: 400 !important;
background-color: transparent;
border: 1px solid var(--fa-gray-300);
transition: border-color 0.15s;
&.is-active {
font-weight: 400;
color: #fff;
background-color: var(--theme-color);
border: 1px solid var(--theme-color);
}
&:hover:not(.is-disabled) {
border-color: var(--theme-color);
}
}
}
:deep(.el-select) {
width: 102px !important;
}
}
/* H5 端:ElPagination 内容较多一行展示不下,改为可换行展示 */
@media (width <= 640px) {
.pagination :deep(.el-pagination) {
display: flex;
flex-wrap: wrap;
gap: 15px 0;
align-items: center;
justify-content: center;
}
}
</style>
@@ -30,7 +30,7 @@ interface Props {
withDefaults(defineProps<Props>(), {
label: "",
type: undefined,
size: "default",
size: undefined,
effect: "light",
round: false,
closable: false,
@@ -0,0 +1,304 @@
<!--
FaDashboardSkeleton:仪表盘页面骨架屏。
提供统计卡片区域 + 图表区域 + 侧边栏的 shimmer 占位。
-->
<template>
<ElSkeleton animated>
<template #template>
<div class="fa-dashboard-skeleton">
<!-- 横幅 Banner 占位 -->
<ElSkeletonItem variant="rect" class="sk-banner" />
<!-- 统计卡片行 -->
<div class="sk-card-row">
<div v-for="i in 4" :key="'card' + i" class="sk-card">
<div class="sk-card__icon">
<ElSkeletonItem variant="circle" />
</div>
<div class="sk-card__body">
<ElSkeletonItem variant="text" class="sk-h-4" />
<ElSkeletonItem variant="text" class="sk-h-6 sk-w-2/3" />
<ElSkeletonItem variant="text" class="sk-h-3 sk-w-1/2" />
</div>
</div>
</div>
<!-- 主区域:图表 + 侧边栏 -->
<div class="sk-main">
<div class="sk-main__left">
<!-- 图表卡片 -->
<div class="sk-chart-card">
<div class="sk-chart-card__header">
<ElSkeletonItem variant="text" class="sk-h-5 sk-w-1/4" />
<ElSkeletonItem variant="text" class="sk-h-3 sk-w-1/6" />
</div>
<ElSkeletonItem variant="rect" class="sk-chart" />
</div>
<!-- 迷你图表行 -->
<div class="sk-mini-row">
<div v-for="i in 3" :key="'mini' + i" class="sk-mini-card">
<ElSkeletonItem variant="text" class="sk-h-4 sk-w-1/2" />
<ElSkeletonItem variant="text" class="sk-h-7" />
<ElSkeletonItem variant="rect" class="sk-mini-chart" />
</div>
</div>
</div>
<div class="sk-main__right">
<!-- 侧边栏:活动列表 -->
<div class="sk-list-card">
<div class="sk-list-card__header">
<ElSkeletonItem variant="text" class="sk-h-5 sk-w-1/3" />
</div>
<div v-for="i in 4" :key="'item' + i" class="sk-list-item">
<ElSkeletonItem variant="circle" class="sk-avatar" />
<div class="sk-list-item__body">
<ElSkeletonItem variant="text" class="sk-h-4" />
<ElSkeletonItem variant="text" class="sk-h-3 sk-w-2/3" />
</div>
</div>
</div>
<!-- 待办列表 -->
<div class="sk-todo-card">
<div class="sk-todo-card__header">
<ElSkeletonItem variant="text" class="sk-h-5 sk-w-1/4" />
</div>
<div v-for="i in 3" :key="'todo' + i" class="sk-todo-item">
<ElSkeletonItem variant="text" class="sk-h-4 sk-w-3/4" />
<ElSkeletonItem variant="text" class="sk-h-3 sk-w-1/3" />
</div>
</div>
</div>
</div>
<!-- 底部卡片行 -->
<div class="sk-bottom-row">
<div v-for="i in 2" :key="'bottom' + i" class="sk-bottom-card">
<div class="sk-bottom-card__header">
<ElSkeletonItem variant="text" class="sk-h-5 sk-w-1/4" />
<ElSkeletonItem variant="text" class="sk-h-3 sk-w-1/6" />
</div>
<ElSkeletonItem variant="rect" class="sk-bottom-chart" />
</div>
</div>
</div>
</template>
</ElSkeleton>
</template>
<style lang="scss" scoped>
.fa-dashboard-skeleton {
display: flex;
flex-direction: column;
gap: 20px;
padding: 8px 0;
/* 通用高度和宽度 */
.sk-h-3 {
height: 12px;
}
.sk-h-4 {
height: 16px;
}
.sk-h-5 {
height: 20px;
}
.sk-h-6 {
height: 24px;
}
.sk-h-7 {
height: 28px;
}
.sk-w-1\2 {
width: 50%;
}
.sk-w-1\3 {
width: 33%;
}
.sk-w-1\4 {
width: 25%;
}
.sk-w-1\6 {
width: 16%;
}
.sk-w-2\3 {
width: 66%;
}
.sk-w-3\4 {
width: 75%;
}
/* Banner */
.sk-banner {
width: 100%;
height: 180px;
border-radius: var(--custom-radius, 12px);
}
/* 统计卡片行 */
.sk-card-row {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
.sk-card {
display: flex;
gap: 12px;
padding: 16px;
background: var(--el-fill-color-lighter);
border-radius: var(--custom-radius, 12px);
.el-skeleton__item--circle {
width: 40px;
height: 40px;
}
.sk-card__body {
display: flex;
flex: 1;
flex-direction: column;
gap: 8px;
}
}
}
/* 主区域 - 图表 + 侧边栏 */
.sk-main {
display: grid;
grid-template-columns: 1fr 280px;
gap: 20px;
.sk-main__left {
display: flex;
flex-direction: column;
gap: 16px;
}
.sk-main__right {
display: flex;
flex-direction: column;
gap: 16px;
}
.sk-chart-card {
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px;
background: var(--el-fill-color-lighter);
border-radius: var(--custom-radius, 12px);
.sk-chart-card__header {
display: flex;
align-items: center;
justify-content: space-between;
}
.sk-chart {
width: 100%;
height: 280px;
border-radius: 8px;
}
}
.sk-mini-row {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
.sk-mini-card {
display: flex;
flex-direction: column;
gap: 8px;
padding: 14px;
background: var(--el-fill-color-lighter);
border-radius: var(--custom-radius, 12px);
.sk-mini-chart {
width: 100%;
height: 60px;
border-radius: 6px;
}
}
}
/* 侧边栏列表 */
.sk-list-card,
.sk-todo-card {
padding: 16px;
background: var(--el-fill-color-lighter);
border-radius: var(--custom-radius, 12px);
.sk-list-card__header,
.sk-todo-card__header {
margin-bottom: 12px;
}
}
.sk-list-item {
display: flex;
gap: 10px;
align-items: center;
padding: 8px 0;
.sk-avatar {
flex-shrink: 0;
width: 32px;
height: 32px;
}
.sk-list-item__body {
display: flex;
flex: 1;
flex-direction: column;
gap: 6px;
}
}
.sk-todo-item {
display: flex;
flex-direction: column;
gap: 6px;
padding: 10px 0;
border-bottom: 1px solid var(--el-border-color-extra-light);
}
}
/* 底部卡片行 */
.sk-bottom-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
.sk-bottom-card {
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px;
background: var(--el-fill-color-lighter);
border-radius: var(--custom-radius, 12px);
.sk-bottom-card__header {
display: flex;
align-items: center;
justify-content: space-between;
}
.sk-bottom-chart {
width: 100%;
height: 180px;
border-radius: 8px;
}
}
}
}
</style>
@@ -63,16 +63,18 @@
>
批量删除
</ElButton>
<ElDropdown v-if="permPatch" v-hasPerm="permPatch" trigger="click">
<ElButton
type="default"
:disabled="removeIds.length === 0 || moreDisabled"
:loading="moreLoading"
>
<template #icon>
<ArrowDown />
</template>
<ElDropdown
v-if="permPatch"
v-hasPerm="permPatch"
trigger="click"
type="info"
:disabled="removeIds.length === 0 || moreDisabled"
>
<ElButton type="info" plain :loading="moreLoading">
更多
<ElIcon class="el-icon--right">
<ArrowDown />
</ElIcon>
</ElButton>
<template #dropdown>
<ElDropdownMenu>
@@ -6,57 +6,83 @@
<template>
<div class="fa-table" :class="{ 'is-empty': isEmpty }">
<div class="fa-table__main">
<VueDraggable
class="fa-table__drag-wrap"
target="tbody"
v-model="dragModel"
:animation="150"
:disabled="rowDragDisabled"
@end="onRowDragEnd"
>
<ElTable ref="elTableRef" v-loading="!!loading" v-bind="mergedTableProps">
<template v-for="col in columns" :key="col.prop || col.type">
<ElTableColumn v-if="col.type === 'globalIndex'" v-bind="{ ...col }">
<template #default="{ $index }">
<span>{{ getGlobalIndex($index) }}</span>
</template>
</ElTableColumn>
<ElTableColumn v-else-if="col.type === 'expand'" v-bind="cleanColumnProps(col)">
<template #default="{ row: expandRow }">
<component :is="col.formatter ? col.formatter(expandRow) : null" />
</template>
</ElTableColumn>
<ElTableColumn v-else v-bind="cleanBodyColumnProps(col)">
<template #header="headerScope">
<component
v-if="col.useHeaderSlot && col.prop"
:is="() => renderColumnHeader(headerScope, col)"
/>
</template>
<template #default="slotScope">
<component
v-if="col.useSlot && col.prop && shouldRenderSlotScope(slotScope)"
:is="() => renderCellSlot(slotScope, col)"
/>
<TableFormatterOutlet
v-else-if="col.formatter && !col.useSlot && shouldRenderSlotScope(slotScope)"
:column="col"
:record="slotScope.row"
/>
</template>
</ElTableColumn>
<!-- 初始加载骨架屏:仅在 loading 且无数据时显示 -->
<div v-if="loading && isEmpty" class="fa-table-skeleton">
<ElSkeleton animated>
<template #template>
<div class="fa-table-skeleton__header">
<ElSkeletonItem variant="rect" class="sk-c-1" />
<ElSkeletonItem variant="rect" class="sk-c-2" />
<ElSkeletonItem variant="rect" class="sk-c-3" />
<ElSkeletonItem variant="rect" class="sk-c-4" />
<ElSkeletonItem variant="rect" class="sk-c-5" />
<ElSkeletonItem variant="rect" class="sk-c-6" />
</div>
<div v-for="i in 8" :key="i" class="fa-table-skeleton__row">
<ElSkeletonItem variant="text" class="sk-c-1" />
<ElSkeletonItem variant="text" class="sk-c-2" />
<ElSkeletonItem variant="text" class="sk-c-3" />
<ElSkeletonItem variant="text" class="sk-c-4" />
<ElSkeletonItem variant="text" class="sk-c-5" />
<ElSkeletonItem variant="text" class="sk-c-6" />
</div>
</template>
<slot v-if="$slots.default" />
<template #empty>
<div v-if="loading"></div>
<ElEmpty v-else :description="emptyText" :image-size="80" />
</template>
</ElTable>
</VueDraggable>
</ElSkeleton>
</div>
<!-- 数据表格 -->
<template v-else>
<VueDraggable
class="fa-table__drag-wrap"
target="tbody"
v-model="dragModel"
:animation="150"
:disabled="rowDragDisabled"
@end="onRowDragEnd"
>
<ElTable ref="elTableRef" :key="tableKey" v-loading="!!loading" v-bind="mergedTableProps">
<template v-for="col in columns" :key="col.prop || col.type">
<ElTableColumn v-if="col.type === 'globalIndex'" v-bind="{ ...col }">
<template #default="{ $index }">
<span>{{ getGlobalIndex($index) }}</span>
</template>
</ElTableColumn>
<ElTableColumn v-else-if="col.type === 'expand'" v-bind="cleanColumnProps(col)">
<template #default="{ row: expandRow }">
<component :is="col.formatter ? col.formatter(expandRow) : null" />
</template>
</ElTableColumn>
<ElTableColumn v-else v-bind="cleanBodyColumnProps(col)">
<template #header="headerScope">
<component
v-if="col.useHeaderSlot && col.prop"
:is="() => renderColumnHeader(headerScope, col)"
/>
</template>
<template #default="slotScope">
<component
v-if="col.useSlot && col.prop && shouldRenderSlotScope(slotScope)"
:is="() => renderCellSlot(slotScope, col)"
/>
<TableFormatterOutlet
v-else-if="col.formatter && !col.useSlot && shouldRenderSlotScope(slotScope)"
:column="col"
:record="slotScope.row"
/>
</template>
</ElTableColumn>
</template>
<slot v-if="$slots.default" />
<template #empty>
<div v-if="loading"></div>
<ElEmpty v-else :description="emptyText" :image-size="80" />
</template>
</ElTable>
</VueDraggable>
</template>
</div>
<div
class="pagination custom-pagination"
class="pagination"
v-if="showPagination"
:class="mergedPaginationOptions?.align"
ref="paginationRef"
@@ -101,10 +127,14 @@ import { useCommon } from "@/hooks/core/useCommon";
import { useTableHeight } from "@/hooks/core/useTableHeight";
import { useWindowSize } from "@vueuse/core";
import { VueDraggable } from "vue-draggable-plus";
import { MOBILE_BREAKPOINT } from "@utils/constants/definitions";
defineOptions({ name: "FaTable" });
const { width } = useWindowSize();
const isMobile = computed(() => width.value < MOBILE_BREAKPOINT);
// H5 ↔ 桌面切换时强制重建 ElTable,使列宽 / formatter 重新计算
const tableKey = computed(() => (isMobile.value ? "mobile" : "desktop"));
const elTableRef = ref<InstanceType<typeof ElTable> | null>(null);
const paginationRef = ref<HTMLElement>();
const tableHeaderRef = ref<HTMLElement>();
@@ -197,7 +227,7 @@ const LAYOUT = {
};
const layout = computed(() => {
if (width.value < 768) {
if (width.value < MOBILE_BREAKPOINT) {
return LAYOUT.MOBILE;
} else if (width.value < 1024) {
return LAYOUT.IPAD;
@@ -213,7 +243,7 @@ const DEFAULT_PAGINATION_OPTIONS: PaginationOptions = {
background: true,
layout: layout.value,
hideOnSinglePage: false,
size: "default",
size: undefined,
pagerCount: width.value > 1200 ? 7 : 5,
};
@@ -305,7 +335,7 @@ const mergedTableProps = computed(() => ({
height: height.value,
stripe: stripe.value,
border: border.value,
size: size.value,
size: hasExplicitTableProp("size") ? size.value : undefined,
headerCellStyle: headerCellStyle.value,
highlightCurrentRow: highlightCurrentRow.value,
// Element Plus 默认值为 true,未显式传入时不应被 FaTable 覆盖成 false。
@@ -420,6 +450,11 @@ const cleanColumnProps = (col: ColumnOption) => {
const cleanBodyColumnProps = (col: ColumnOption) => {
const columnProps = cleanColumnProps(col);
delete columnProps.formatter;
// H5 端:操作列只显示「更多」按钮,宽度收窄到 80;响应式跟随窗口变化
const isOpCol = col.prop === "operation" || col.label === "操作";
if (isOpCol && isMobile.value) {
columnProps.width = 80;
}
return columnProps;
};
@@ -514,6 +549,57 @@ defineExpose({
}
}
/* ── 表格骨架屏 ── */
.fa-table-skeleton {
padding: 12px 0;
.fa-table-skeleton__header {
display: grid;
grid-template-columns: 56px 1fr 1fr 120px 1fr 100px;
gap: 4px;
padding: 10px 16px;
margin-bottom: 4px;
border-bottom: 1px solid var(--el-border-color-lighter);
.el-skeleton__item {
height: 22px;
}
}
.fa-table-skeleton__row {
display: grid;
grid-template-columns: 56px 1fr 1fr 120px 1fr 100px;
gap: 4px;
align-items: center;
padding: 12px 16px;
}
/* 每行 6 列宽度的别名 */
.sk-c-1 {
grid-column: 1;
}
.sk-c-2 {
grid-column: 2;
}
.sk-c-3 {
grid-column: 3;
}
.sk-c-4 {
grid-column: 4;
}
.sk-c-5 {
grid-column: 5;
}
.sk-c-6 {
grid-column: 6;
}
}
.el-table {
height: 100%;
}
@@ -541,15 +627,12 @@ defineExpose({
}
}
/* 分页按钮样式已统一由 FaPagination 组件处理 */
.pagination {
display: flex;
flex-shrink: 0;
padding-top: 13px;
:deep(.el-select) {
width: 102px !important;
}
/* 分页对齐方式 */
&.left {
justify-content: flex-start;
@@ -562,53 +645,6 @@ defineExpose({
&.right {
justify-content: flex-end;
}
/* 自定义分页组件样式 */
&.custom-pagination {
:deep(.el-pagination) {
.btn-prev,
.btn-next {
background-color: transparent;
border: 1px solid var(--fa-gray-300);
transition: border-color 0.15s;
&:hover:not(.is-disabled) {
color: var(--theme-color);
border-color: var(--theme-color);
}
}
li {
box-sizing: border-box;
font-weight: 400 !important;
background-color: transparent;
border: 1px solid var(--fa-gray-300);
transition: border-color 0.15s;
&.is-active {
font-weight: 400;
color: #fff;
background-color: var(--theme-color);
border: 1px solid var(--theme-color);
}
&:hover:not(.is-disabled) {
border-color: var(--theme-color);
}
}
}
}
}
}
/* 移动端分页 */
@media (width <= 640px) {
:deep(.el-pagination) {
display: flex;
flex-wrap: wrap;
gap: 15px 0;
align-items: center;
justify-content: center;
}
}
</style>
@@ -1,20 +1,18 @@
<!-- 节日 / 公告顶栏:文案来自 festival 配置(占位符 {{version}}、{{introduceUrl}}) -->
<template>
<div
class="overflow-hidden transition-[height] duration-300 ease-in-out"
:style="{ height: showFestivalStrip ? '48px' : '0' }"
>
<FaTextScroll
v-if="showFestivalStrip"
class="mb-3!"
type="theme"
:text="festivalScrollDisplayHtml"
height="40px"
:speed="55"
:always-scroll="true"
show-close
@close="closeFestivalScroll"
/>
<div :class="{ 'mb-5': showFestivalStrip }">
<Transition name="festival-strip">
<FaTextScroll
v-if="showFestivalStrip"
type="theme"
:text="festivalScrollDisplayHtml"
height="40px"
:speed="55"
:always-scroll="true"
show-close
@close="closeFestivalScroll"
/>
</Transition>
</div>
</template>
@@ -221,8 +221,11 @@ html.dark .login-page-footer--floating-layout {
color: var(--fa-gray-600);
}
.custom-height {
height: 40px !important;
// 桌面端保持原始高度;H5 由 ConfigProvider size="small" 接管
@media (width >= 768px) {
.custom-height {
height: 40px;
}
}
}
@@ -259,12 +262,15 @@ html.dark .login-page-panel {
}
.el-select__wrapper {
min-height: 42px !important;
min-height: 42px;
}
.login-secondary-actions .login-secondary-btn {
height: 42px;
border-radius: 8px;
// 桌面端按钮保持原始高度;H5 由 ConfigProvider size="small" 接管
@media (width >= 768px) {
.login-secondary-actions .login-secondary-btn {
height: 42px;
border-radius: 8px;
}
}
html.dark {
@@ -311,8 +317,10 @@ html.dark {
}
}
.login-secondary-actions .login-secondary-btn {
height: 42px;
@media (width >= 768px) {
.login-secondary-actions .login-secondary-btn {
height: 42px;
}
}
}
@@ -70,14 +70,11 @@
@keyup.enter="$emit('submit')"
>
<template #prefix>
<FaSvgIcon
icon="mdi:shield-lock-outline"
class="size-[18px] text-(--el-text-color-secondary)"
/>
<ElIcon><Unlock /></ElIcon>
</template>
</ElInput>
<div
class="login-captcha-img flex h-10 w-[100px] shrink-0 cursor-pointer items-center justify-center overflow-hidden rounded"
class="login-captcha-img flex h-10 max-md:h-6 w-[100px] max-md:w-[80px] shrink-0 cursor-pointer items-center justify-center overflow-hidden rounded"
role="button"
:title="$t('login.captchaClickHint')"
@click="$emit('getCaptcha')"
@@ -171,7 +168,7 @@
</template>
<script setup lang="ts">
import { Loading, Lock, User } from "@element-plus/icons-vue";
import { Loading, Lock, Unlock, User } from "@element-plus/icons-vue";
import type { CaptchaInfo, LoginFormData } from "@/api/module_system/auth";
import type { FormRules } from "element-plus";
import type { Account, AccountKey } from "@views/module_system/auth/login/types";
@@ -16,7 +16,7 @@
</div>
<div
class="auth-top-bar-actions-panel pointer-events-auto flex shrink-0 flex items-center justify-center gap-1.5 px-2 py-1.5 max-sm:mr-1"
class="auth-top-bar-actions-panel pointer-events-auto flex shrink-0 items-center justify-center gap-1.5 px-2 py-1.5 max-sm:mr-1"
>
<div class="color-picker-expandable relative flex items-center max-sm:hidden!">
<div
@@ -6,7 +6,9 @@
<span class="divider-text">{{ $t("login.otherLoginMethods") }}</span>
<div class="divider-line" />
</div>
<div class="login-third-party-icons flex w-full items-center justify-center gap-x-3 sm:gap-x-4">
<div
class="login-third-party-icons flex w-full items-center justify-center gap-x-3 sm:gap-x-4 max-sm:gap-x-2"
>
<ElTooltip
v-for="item in oauthItems"
:key="item.provider"
@@ -15,7 +17,7 @@
>
<button
type="button"
class="oauth-social-btn flex size-10 shrink-0 cursor-pointer items-center justify-center rounded-full border-0 bg-transparent transition-colors duration-200 outline-none"
class="oauth-social-btn flex size-10 max-sm:size-8 shrink-0 cursor-pointer items-center justify-center rounded-full border-0 bg-transparent transition-colors duration-200 outline-none"
:aria-label="item.tip"
@click="$emit('oauth', item.provider)"
>
@@ -44,25 +46,25 @@ const oauthItems = computed(() => [
provider: "wechat" as const,
tip: t("login.oauthTooltip.wechat"),
icon: "simple-icons:wechat",
iconClass: "size-[22px] text-[#07c160]",
iconClass: "size-[22px] max-sm:size-[18px] text-[#07c160]",
},
{
provider: "qq" as const,
tip: t("login.oauthTooltip.qq"),
icon: "simple-icons:tencentqq",
iconClass: "size-[22px] text-[#12b7f5]",
iconClass: "size-[22px] max-sm:size-[18px] text-[#12b7f5]",
},
{
provider: "github" as const,
tip: t("login.oauthTooltip.github"),
icon: "mdi:github",
iconClass: "size-[22px] text-g-800 dark:text-white/85",
iconClass: "size-[22px] max-sm:size-[18px] text-g-800 dark:text-white/85",
},
{
provider: "gitee" as const,
tip: t("login.oauthTooltip.gitee"),
icon: "simple-icons:gitee",
iconClass: "size-[22px] text-[#c71d23]",
iconClass: "size-[22px] max-sm:size-[18px] text-[#c71d23]",
},
]);
</script>
@@ -1,7 +1,7 @@
<!-- 按钮组件 -->
<template>
<div
class="size-8.5 inline-flex flex items-center justify-center cursor-pointer text-g-600 dark:text-g-800 text-xl rounded transition duration-300 hover:bg-hover-color"
class="fa-icon-button size-8.5 inline-flex items-center justify-center cursor-pointer text-g-600 dark:text-g-800 text-xl rounded transition duration-300 hover:bg-hover-color"
:class="{ 'rounded-full': circle }"
>
<FaSvgIcon :icon="icon"></FaSvgIcon>
@@ -21,3 +21,11 @@ interface Props {
withDefaults(defineProps<Props>(), {});
</script>
<style lang="scss" scoped>
.fa-icon-button {
&:hover :deep(.fa-svg-icon) {
color: var(--el-color-primary);
}
}
</style>