refactor: 完成项目前缀从Art到Fa的全面重构

本次提交完成了代码库中所有以Art为前缀的组件、类名、样式变量等的统一替换,改为Fa前缀,包括:
1.  替换所有ArtException、ArtDataListCard等组件引用
2.  统一样式变量名从art-*为fa-*
3.  修改配置文件、工具函数中的相关命名
4.  调整国际化文案与组件命名对齐
5.  修复部分样式和逻辑代码因命名变更导致的问题
6.  清理冗余代码与废弃的类型定义
This commit is contained in:
zhangtao
2026-05-11 13:10:18 +08:00
parent 1ac2362945
commit 4112942a14
184 changed files with 2573 additions and 2976 deletions
+2 -2
View File
@@ -16,7 +16,7 @@
<RouterView></RouterView>
<!-- AI 助手 -->
<FaAiAssistant v-if="enableAiAssistant" />
<AiAssistant v-if="enableAiAssistant" />
</ElWatermark>
</ElConfigProvider>
</template>
@@ -27,7 +27,7 @@ import { useAppStore, useUserStore } from "./store";
import { useSettingsStore } from "./store/modules/setting.store";
import { defaultSettings } from "./config/setting";
import { ComponentSize } from "./enums/settings/layout.enum";
import FaAiAssistant from "./components/others/fa-ai-assistant/index.vue";
import AiAssistant from "./components/others/fa-ai-assistant/index.vue";
import { hexToRgba, toggleTransition } from "./utils/ui";
import { checkStorageCompatibility } from "./utils/storage";
import { initializeTheme } from "./hooks/core/useTheme";
+1 -1
View File
@@ -29,4 +29,4 @@ export const fourDotsSpinnerSvg = `
<circle class="dot" cx="8" cy="20" r="4"/>
</g>
</svg>
`
`;
@@ -9,7 +9,7 @@ $box-radius: calc(var(--custom-radius) / 3 + 2px);
.editor-wrapper {
width: 100%;
height: 100%;
border: 1px solid var(--art-gray-300);
border: 1px solid var(--fa-gray-300);
border-radius: $box-radius !important;
.w-e-bar {
@@ -53,12 +53,12 @@ $box-radius: calc(var(--custom-radius) / 3 + 2px);
/* 下拉选择框 hover 样式调整 */
.w-e-select-list ul li:hover {
background-color: var(--art-gray-200);
background-color: var(--fa-gray-200);
}
:root {
/* 激活颜色 */
--w-e-toolbar-active-bg-color: var(--art-gray-200);
--w-e-toolbar-active-bg-color: var(--fa-gray-200);
/* toolbar 图标和文字颜色 */
--w-e-toolbar-color: #000;
@@ -67,22 +67,22 @@ $box-radius: calc(var(--custom-radius) / 3 + 2px);
--w-e-textarea-selected-border-color: #ddd;
/* 表格头背景颜色 */
--w-e-textarea-slight-bg-color: var(--art-gray-200);
--w-e-textarea-slight-bg-color: var(--fa-gray-200);
}
/* 工具栏按钮样式 */
.w-e-bar-item svg {
fill: var(--art-gray-800);
fill: var(--fa-gray-800);
}
.w-e-bar-item button {
color: var(--art-gray-800);
color: var(--fa-gray-800);
border-radius: $box-radius;
}
/* 工具栏 hover 按钮背景颜色 */
.w-e-bar-item button:hover {
background-color: var(--art-gray-200);
background-color: var(--fa-gray-200);
}
/* 工具栏分割线 */
@@ -110,7 +110,7 @@ $box-radius: calc(var(--custom-radius) / 3 + 2px);
/* 代码块 */
.w-e-text-container [data-slate-editor] pre > code {
padding: 0.6rem 1rem;
background-color: var(--art-gray-50);
background-color: var(--fa-gray-50);
border-radius: $box-radius;
}
@@ -210,8 +210,8 @@ $box-radius: calc(var(--custom-radius) / 3 + 2px);
/* 引用 */
.w-e-text-container [data-slate-editor] blockquote {
background-color: var(--art-gray-200);
border-left: 4px solid var(--art-gray-300);
background-color: var(--fa-gray-200);
border-left: 4px solid var(--fa-gray-300);
}
/* 输入区域弹出 bar */
@@ -367,27 +367,27 @@ const highlightOnHoverHistory = (index: number) => {
</script>
<style lang="scss" scoped>
.layout-search {
:deep(.search-modal) {
::deep(.search-modal) {
background-color: rgb(0 0 0 / 20%);
}
:deep(.el-dialog__body) {
::deep(.el-dialog__body) {
padding: 5px 0 0 !important;
}
:deep(.el-dialog__header) {
::deep(.el-dialog__header) {
padding: 0;
}
.el-input {
:deep(.el-input__wrapper) {
::deep(.el-input__wrapper) {
background-color: var(--fa-gray-200);
border: 1px solid var(--default-border-dashed);
border-radius: calc(var(--custom-radius) / 2 + 2px) !important;
box-shadow: none;
}
:deep(.el-input__inner) {
::deep(.el-input__inner) {
color: var(--fa-gray-800) !important;
}
}
@@ -395,18 +395,18 @@ const highlightOnHoverHistory = (index: number) => {
.dark .layout-search {
.el-input {
:deep(.el-input__wrapper) {
::deep(.el-input__wrapper) {
background-color: #333;
border: 1px solid #4c4d50;
}
}
:deep(.search-modal) {
::deep(.search-modal) {
background-color: rgb(23 23 26 / 60%);
backdrop-filter: none;
}
:deep(.el-dialog) {
::deep(.el-dialog) {
background-color: #252526;
}
}
@@ -463,35 +463,35 @@ const openChat = (): void => {
}
/* Hover animation classes */
.refresh-btn:hover :deep(.fa-svg-icon) {
.refresh-btn:hover ::deep(.fa-svg-icon) {
animation: rotate180 0.5s;
}
.language-btn:hover :deep(.fa-svg-icon) {
.language-btn:hover ::deep(.fa-svg-icon) {
animation: moveUp 0.4s;
}
.setting-btn:hover :deep(.fa-svg-icon) {
.setting-btn:hover ::deep(.fa-svg-icon) {
animation: rotate180 0.5s;
}
.full-screen-btn:hover :deep(.fa-svg-icon) {
.full-screen-btn:hover ::deep(.fa-svg-icon) {
animation: expand 0.6s forwards;
}
:deep(.size-select-btn:hover .fa-svg-icon) {
::deep(.size-select-btn:hover .fa-svg-icon) {
animation: expand 0.6s forwards;
}
.exit-full-screen-btn:hover :deep(.fa-svg-icon) {
.exit-full-screen-btn:hover ::deep(.fa-svg-icon) {
animation: shrink 0.6s forwards;
}
.notice-button:hover :deep(.fa-svg-icon) {
.notice-button:hover ::deep(.fa-svg-icon) {
animation: shake 0.5s ease-in-out;
}
.chat-button:hover :deep(.fa-svg-icon) {
.chat-button:hover ::deep(.fa-svg-icon) {
animation: shake 0.5s ease-in-out;
}
@@ -247,7 +247,7 @@ function closeUserMenu(): void {
}
/* ElPopover 基于 Tooltip:触发层默认 inline-block,与顶栏 flex 图标中线对齐 */
.fa-user-menu :deep(.el-tooltip__trigger) {
.fa-user-menu ::deep(.el-tooltip__trigger) {
display: inline-flex !important;
align-items: center;
line-height: 1;
@@ -92,18 +92,18 @@ const filterMenuItems = (items: AppRouteRecord[]): AppRouteRecord[] => {
<style scoped>
/* Remove el-menu bottom border */
:deep(.el-menu) {
::deep(.el-menu) {
border-bottom: none !important;
}
/* Remove default styles for first-level menu items */
:deep(.el-menu-item[tabindex="0"]) {
::deep(.el-menu-item[tabindex="0"]) {
background-color: transparent !important;
border: none !important;
}
/* Remove bottom border from submenu titles */
:deep(.el-menu--horizontal .el-sub-menu__title) {
::deep(.el-menu--horizontal .el-sub-menu__title) {
padding: 0 30px 0 10px !important;
border: 0 !important;
}
@@ -49,6 +49,7 @@ import { computed, type PropType } from "vue";
import { AppRouteRecord } from "@/types/router";
import { handleMenuJump } from "@utils/navigation";
import { formatMenuTitle } from "@utils/navigation";
import FaMenuRouteIcon from "@/components/others/fa-menu-routeIcon/index.vue";
const props = defineProps({
item: {
@@ -105,7 +106,7 @@ const closeMenu = () => {
</script>
<style scoped>
:deep(.el-sub-menu__title .el-sub-menu__icon-arrow) {
::deep(.el-sub-menu__title .el-sub-menu__icon-arrow) {
right: 10px !important;
}
</style>
@@ -59,6 +59,7 @@ import { useThrottleFn } from "@vueuse/core";
import { formatMenuTitle } from "@utils/navigation";
import { handleMenuJump } from "@utils/navigation";
import type { AppRouteRecord } from "@/types/router";
import FaMenuRouteIcon from "@/components/others/fa-menu-routeIcon/index.vue";
defineOptions({ name: "FaMixedMenu" });
@@ -247,13 +248,13 @@ onMounted(initScrollState);
</style>
<style scoped>
:deep(.el-scrollbar__bar.is-horizontal) {
::deep(.el-scrollbar__bar.is-horizontal) {
bottom: 5px;
display: none;
height: 2px;
}
:deep(.scrollbar-wrapper) {
::deep(.scrollbar-wrapper) {
flex: 1;
min-width: 0;
margin: 0 50px 0 30px;
@@ -272,7 +273,7 @@ onMounted(initScrollState);
}
@media (width <= 1440px) {
:deep(.scrollbar-wrapper) {
::deep(.scrollbar-wrapper) {
margin: 0 45px;
}
}
@@ -3,18 +3,18 @@
height: 100vh;
user-select: none;
scrollbar-width: none;
border-right: 1px solid var(--art-card-border);
border-right: 1px solid var(--fa-card-border);
&.no-border {
border-right: none !important;
}
// 自定义滚动条宽度
:deep(.el-scrollbar__bar.is-vertical) {
::deep(.el-scrollbar__bar.is-vertical) {
width: 4px;
}
:deep(.el-scrollbar__thumb) {
::deep(.el-scrollbar__thumb) {
right: -2px;
background-color: #ccc;
border-radius: 2px;
@@ -24,7 +24,7 @@
position: relative;
width: 80px;
height: 100%;
border-right: 1px solid var(--art-card-border) !important;
border-right: 1px solid var(--fa-card-border) !important;
transition: width 0.25s;
.logo {
@@ -114,7 +114,7 @@
height: 50px;
cursor: pointer;
background-color: var(--default-box-color);
border: 1px solid var(--art-card-border);
border: 1px solid var(--fa-card-border);
border-radius: 0 15px 15px 0;
opacity: 0;
transform: translateY(-50%);
@@ -122,7 +122,7 @@
&:hover {
.art-svg-icon {
color: var(--art-gray-800) !important;
color: var(--fa-gray-800) !important;
}
}
@@ -240,7 +240,7 @@
.layout-sidebar {
border-right: 1px solid rgb(255 255 255 / 13%);
:deep(.el-scrollbar__thumb) {
::deep(.el-scrollbar__thumb) {
background-color: #777;
}
@@ -4,7 +4,7 @@
$menu-height: 42px;
$menu-icon-size: 20px;
$menu-font-size: 14px;
$hover-bg-color: var(--art-gray-200);
$hover-bg-color: var(--fa-gray-200);
$popup-menu-height: 40px;
$popup-menu-padding: 8px;
$popup-menu-margin: 5px;
@@ -158,7 +158,7 @@ $popup-menu-radius: 6px;
@include menu-hover($hover-bg-color);
.el-sub-menu__icon-arrow {
color: var(--art-gray-600);
color: var(--fa-gray-600);
}
}
@@ -169,7 +169,7 @@ $popup-menu-radius: 6px;
@include menu-hover(#0f1015);
.el-sub-menu__icon-arrow {
color: var(--art-gray-400);
color: var(--fa-gray-400);
}
}
@@ -203,7 +203,7 @@ $popup-menu-radius: 6px;
@include menu-hover($hover-bg-color);
.el-sub-menu__icon-arrow {
color: var(--art-gray-600);
color: var(--fa-gray-600);
}
}
}
@@ -225,21 +225,21 @@ $popup-menu-radius: 6px;
// 菜单折叠 hover 弹窗样式(浅色主题)
.el-menu--vertical,
.el-menu--popup-container {
@include popup-menu-base(var(--art-gray-200), var(--art-gray-900), var(--art-gray-200));
@include popup-menu-base(var(--fa-gray-200), var(--fa-gray-900), var(--fa-gray-200));
}
// 暗黑模式菜单样式
.dark {
.el-menu--vertical,
.el-menu--popup-container {
@include popup-menu-base(var(--art-gray-200), var(--art-gray-900), #292a2e);
@include popup-menu-base(var(--fa-gray-200), var(--fa-gray-900), #292a2e);
}
.layout-sidebar {
// 图标颜色、文字颜色
.menu-icon .art-svg-icon,
.menu-name {
color: var(--art-gray-800) !important;
color: var(--fa-gray-800) !important;
}
// 选中的文字颜色跟图标颜色
@@ -15,20 +15,26 @@
</div>
<RouterView v-if="isRefresh" v-slot="{ Component, route: router }" :style="contentStyle">
<Transition :name="actualTransition" mode="out-in" appear>
<div class="route-view-shell flex min-h-0 min-w-0 w-full flex-1 flex-col">
<KeepAlive :max="10" :exclude="keepAliveExclude">
<Transition :name="actualTransition" mode="out-in">
<div v-if="Component" class="route-view-shell flex min-h-0 min-w-0 w-full flex-1 flex-col">
<!-- 是否缓存以后端菜单 keep_alive meta.keepAlive 为准此处 !== false 即包 KeepAlive MenuProcessor 一致 -->
<KeepAlive
v-if="wrapPageWithKeepAlive"
:max="10"
:include="keepAliveInclude"
:exclude="keepAliveExclude"
>
<component
class="fa-page-view min-h-0 min-w-0 w-full flex-1"
:is="Component"
:key="router.fullPath"
:key="routeLeafCacheKey(router)"
/>
</KeepAlive>
<component
v-else
class="fa-page-view min-h-0 min-w-0 w-full flex-1"
:is="Component"
:key="router.fullPath"
:key="routeLeafCacheKey(router)"
/>
</div>
</Transition>
@@ -47,6 +53,12 @@
</div>
</template>
<script setup lang="ts">
/**
* 布局滚动容器 + 业务路由出口 settings.refresh 联动可整体重建 RouterView
*
* 缓存开关数据源后端菜单 `keep_alive` MenuProcessor 写入 `meta.keepAlive`工作栏 tab 随路由写入同一 meta
* include / wrapPageWithKeepAlive 均用 `!== false`与静态路由里显式 `keepAlive: false`后端布尔字段对齐
*/
import type { CSSProperties } from "vue";
import { useMediaQuery } from "@vueuse/core";
import { useRoute, type RouteLocationNormalizedLoaded } from "vue-router";
@@ -56,18 +68,47 @@ import { useWorktabStore } from "@stores/modules/worktab.store";
defineOptions({ name: "FaPageContent" });
/**
* KeepAlive / RouterView 子节点缓存键
* - `meta.remountOnFullPath === true` URL 参与键依赖 query 初值或须随 query 重建的页
* - `name``name` + `params`同一路由仅 query 变化不换实例
* - `name``path`勿用 fullPath否则仅 query 变化也会反复挂载
*/
function routeLeafCacheKey(r: RouteLocationNormalizedLoaded): string {
if (r.meta.remountOnFullPath === true) {
return r.fullPath;
}
if (r.name != null) {
return `${String(r.name)}:${JSON.stringify(r.params ?? {})}`;
}
return r.path;
}
const route = useRoute();
/** 动态菜单 meta.keepAlive(后端 keep_alive);仅显式 false 时不包 KeepAlive */
const wrapPageWithKeepAlive = computed(() => route.meta.keepAlive !== false);
const isNarrowViewport = useMediaQuery("(max-width: 800px)");
const backtopScrollTarget = computed(() => (isNarrowViewport.value ? "" : "#app-scroll-main"));
const backtopTargetKey = computed(() => (isNarrowViewport.value ? "win" : "main"));
const { pageTransition, containerWidth, refresh } = storeToRefs(useSettingsStore());
const { keepAliveExclude } = storeToRefs(useWorktabStore());
const { pageTransition, containerWidth, refresh, showWorkTab } = storeToRefs(useSettingsStore());
const { keepAliveExclude, opened } = storeToRefs(useWorktabStore());
/** 多标签开启时:仅已打开且允许缓存的标签组件名进入 include;关闭多标签时不传 include,避免 opened 过窄误伤缓存。 */
const keepAliveInclude = computed(() => {
if (!showWorkTab.value) return undefined;
const names = new Set<string>();
for (const t of opened.value) {
if (t.name && t.keepAlive !== false) names.add(String(t.name));
}
return names.size ? Array.from(names) : undefined;
});
const isRefresh = shallowRef(true);
const isOpenRouteInfo = import.meta.env.VITE_OPEN_ROUTE_INFO;
//
/** 浏览器首次进入:关闭路由过渡动画,避免首屏闪动 */
const isFirstLoad = ref(true);
const actualTransition = computed(() => {
@@ -54,7 +54,7 @@
</div>
<!-- 解锁全屏旧版 LockPage 样式 -->
<div v-else class="lockpage">
<div v-else class="lockpage" :style="lockPageBgStyle">
<div v-show="showClock" class="unlock-container" @click="showUnlockForm">
<ElIcon><Lock /></ElIcon>
<span>{{ t("lock.unlock") }}</span>
@@ -148,8 +148,11 @@ import { useI18n } from "vue-i18n";
import { useRoute, useRouter } from "vue-router";
import CryptoJS from "crypto-js";
import { useUserStore } from "@stores/modules/user.store";
import { useSettingsStore } from "@stores/modules/setting.store";
import { mittBus } from "@utils/sys";
import { useNow } from "@utils/common";
import bgDark from "@imgs/lock/bg_dark.webp";
import bgLight from "@imgs/lock/bg_light.webp";
const { t } = useI18n();
const route = useRoute();
@@ -158,7 +161,20 @@ const router = useRouter();
const ENCRYPT_KEY = import.meta.env.VITE_LOCK_ENCRYPT_KEY;
const userStore = useUserStore();
const settingsStore = useSettingsStore();
const { info: userInfo, lockPassword, isLock } = storeToRefs(userStore);
const { isDark } = storeToRefs(settingsStore);
/** 锁屏全屏背景:随明暗主题切换壁纸,叠加半透明罩层保证时钟/文案可读 */
const lockPageBgStyle = computed(() => {
const url = isDark.value ? bgDark : bgLight;
return {
backgroundImage: `linear-gradient(rgb(0 0 0 / 46%), rgb(0 0 0 / 46%)), url(${url})`,
backgroundSize: "cover",
backgroundPosition: "center",
backgroundRepeat: "no-repeat",
};
});
const { hour, month, minute, meridiem, year, day, week } = useNow(true);
@@ -187,7 +203,7 @@ const unlockLoading = ref(false);
const formRef = ref<FormInstance>();
const formData = ref({
const formData = reactive({
password: "",
});
@@ -525,8 +541,6 @@ onUnmounted(() => {
width: 100%;
height: 100%;
color: var(--el-color-white);
background-color: rgb(0 0 0 / 90%);
backdrop-filter: blur(8px);
.unlock-container {
position: absolute;
@@ -80,7 +80,7 @@
}
// 去除火狐浏览器滚动条
:deep(.el-drawer__body) {
::deep(.el-drawer__body) {
scrollbar-width: none;
}
@@ -1,4 +1,4 @@
<!-- 三种模式与 fa-design-pro 一致tab-default / tab-card / tab-google参考 fa-design-pro/src/components/layouts/fa-work-tab -->
<!-- 三种模式与 art-design-pro 一致tab-default / tab-card / tab-google参考 art-design-pro/src/components/core/layouts/art-work-tab -->
<template>
<div
v-if="showWorkTab"
@@ -542,7 +542,15 @@ const useTabOperations = (adjustPositionAfterClose: () => void) => {
const shouldNavigate = navigationRules[key as keyof typeof navigationRules];
if (shouldNavigate) {
router.push(clickedPath.value);
const dest = list.value.find((tab) => tab.path === clickedPath.value);
if (dest) {
router.push({
path: dest.path,
query: dest.query as LocationQueryRaw,
});
} else {
router.push(clickedPath.value);
}
}
closeWorktab(key as TabCloseType, clickedPath.value);
@@ -563,7 +571,7 @@ const { setupEventListeners, cleanupEventListeners, adjustPositionAfterClose } =
const { clickTab, closeWorktab, showMenu, handleSelect } =
useTabOperations(adjustPositionAfterClose);
/** 与旧版 TagsView 一致的横向滚动步长 */
/** 标签横向滚动步长px */
const SCROLL_STEP = 200;
/**
@@ -33,7 +33,7 @@
padding: 20px;
overflow: auto;
:deep(.layout-content) {
::deep(.layout-content) {
box-sizing: border-box;
width: 100%;
min-width: 0;
@@ -80,7 +80,7 @@
overflow: visible;
#app-content {
:deep(.layout-content) {
::deep(.layout-content) {
width: calc(100% - 40px);
}
}
@@ -92,7 +92,7 @@
.app-layout {
#app-main {
#app-content {
:deep(.layout-content) {
::deep(.layout-content) {
width: calc(100% - 30px);
}
}
@@ -283,11 +283,11 @@ function downloadImg() {
}
}
:deep(.toolBoxControl) {
::deep(.toolBoxControl) {
z-index: 100;
}
:deep(.dockMain) {
::deep(.dockMain) {
right: 0;
bottom: -40px;
left: 0;
@@ -297,15 +297,15 @@ function downloadImg() {
opacity: 1;
}
:deep(.copyright) {
::deep(.copyright) {
display: none !important;
}
:deep(.i-dialog-footer) {
::deep(.i-dialog-footer) {
margin-top: 60px !important;
}
:deep(.dockBtn) {
::deep(.dockBtn) {
height: 26px;
padding: 0 10px;
font-size: 12px;
@@ -315,31 +315,31 @@ function downloadImg() {
border: 1px solid var(--el-color-primary-light-4) !important;
}
:deep(.dockBtnScrollBar) {
::deep(.dockBtnScrollBar) {
margin: 0 10px 0 6px;
background-color: var(--el-color-primary-light-1);
}
:deep(.scrollBarControl) {
::deep(.scrollBarControl) {
border-color: var(--el-color-primary);
}
:deep(.closeIcon) {
::deep(.closeIcon) {
line-height: 15px !important;
}
}
.dark {
.cutter-container {
:deep(.toolBox) {
::deep(.toolBox) {
border: transparent;
}
:deep(.dialogMain) {
::deep(.dialogMain) {
background-color: transparent !important;
}
:deep(.i-dialog-footer) {
::deep(.i-dialog-footer) {
.btn {
background-color: var(--el-color-primary) !important;
border: transparent;
@@ -119,7 +119,7 @@ const dialogAttrs = computed(() => {
align-items: center;
margin-left: auto;
:deep(.core-overlay-icon-btn.el-button) {
::deep(.core-overlay-icon-btn.el-button) {
min-width: 32px;
padding: 6px;
border-radius: var(--el-border-radius-base);
@@ -99,7 +99,7 @@ const drawerAttrs = computed(() => {
align-items: center;
margin-left: auto;
:deep(.core-overlay-icon-btn.el-button) {
::deep(.core-overlay-icon-btn.el-button) {
min-width: 32px;
padding: 6px;
border-radius: var(--el-border-radius-base);
@@ -75,6 +75,7 @@
</template>
<script setup lang="ts">
import { ref, computed } from "vue";
import { dayjs } from "element-plus";
defineOptions({ name: "FaCalendar" });
@@ -204,45 +205,45 @@ const handleDeleteEvent = () => {
min-width: 0;
}
:deep(.el-calendar__header) {
::deep(.el-calendar__header) {
padding: 6px 4px;
border-bottom: none;
}
:deep(.el-calendar__title) {
::deep(.el-calendar__title) {
font-size: 14px;
font-weight: 600;
}
:deep(.el-calendar__header .el-button) {
::deep(.el-calendar__header .el-button) {
padding: 4px 8px;
}
:deep(.el-calendar__body) {
::deep(.el-calendar__body) {
padding: 2px 0 4px;
}
:deep(.el-calendar-table thead th) {
::deep(.el-calendar-table thead th) {
padding: 4px 0;
font-size: 11px;
font-weight: 500;
}
:deep(.is-selected) {
::deep(.is-selected) {
background-color: var(--el-color-warning-light-9) !important;
}
:deep(.el-calendar-day) {
::deep(.el-calendar-day) {
height: auto;
min-height: 3rem;
padding: 0;
}
:deep(.el-calendar-day:hover) {
::deep(.el-calendar-day:hover) {
background-color: transparent !important;
}
:deep(.el-dialog__body) {
::deep(.el-dialog__body) {
padding-top: 20px;
}
</style>
@@ -181,8 +181,8 @@ onMounted(() => {
</script>
<style scoped lang="scss">
.reference :deep(.el-input__wrapper),
.reference :deep(.el-input__inner) {
.reference ::deep(.el-input__wrapper),
.reference ::deep(.el-input__inner) {
cursor: pointer;
}
@@ -70,7 +70,7 @@ const renderedContent = computed(() => {
color: var(--el-text-color-primary);
word-wrap: break-word;
:deep(pre) {
::deep(pre) {
padding: 12px;
margin: 12px 0;
overflow-x: auto;
@@ -83,7 +83,7 @@ const renderedContent = computed(() => {
}
}
:deep(code) {
::deep(code) {
padding: 2px 6px;
font-family: "Courier New", Courier, monospace;
font-size: 13px;
@@ -91,21 +91,21 @@ const renderedContent = computed(() => {
border-radius: 3px;
}
:deep(p) {
::deep(p) {
margin: 8px 0;
}
:deep(ul),
:deep(ol) {
::deep(ul),
::deep(ol) {
padding-left: 24px;
margin: 8px 0;
}
:deep(li) {
::deep(li) {
margin: 4px 0;
}
:deep(a) {
::deep(a) {
color: var(--el-color-primary);
text-decoration: none;
@@ -114,14 +114,14 @@ const renderedContent = computed(() => {
}
}
:deep(blockquote) {
::deep(blockquote) {
padding: 8px 16px;
margin: 12px 0;
background: var(--el-fill-color-light);
border-left: 4px solid var(--el-color-primary);
}
:deep(table) {
::deep(table) {
width: 100%;
margin: 12px 0;
border-collapse: collapse;
@@ -84,7 +84,9 @@
</template>
<script setup lang="ts">
import { ref, computed } from "vue";
import type { CSSProperties } from "vue";
import FaSvgIcon from "@/components/base/fa-svg-icon/index.vue";
defineOptions({ name: "FaMenuRight" });
@@ -382,7 +384,7 @@ defineExpose({
}
.menu-item.is-disabled i:not(.submenu-arrow),
.menu-item.is-disabled :deep(.fa-svg-icon) {
.menu-item.is-disabled ::deep(.fa-svg-icon) {
color: var(--el-text-color-disabled) !important;
}
@@ -100,5 +100,4 @@ function handleCurrentChange(val: number) {
}
</script>
<style lang="scss" scoped>
</style>
<style lang="scss" scoped></style>
@@ -352,8 +352,8 @@ const popoverContentRef = ref();
</script>
<style scoped lang="scss">
.reference :deep(.el-input__wrapper),
.reference :deep(.el-input__inner) {
.reference ::deep(.el-input__wrapper),
.reference ::deep(.el-input__inner) {
cursor: pointer;
}
@@ -363,7 +363,7 @@ const popoverContentRef = ref();
margin-top: 6px;
}
//
.radio :deep(.el-table__header th.el-table__cell:nth-child(1) .el-checkbox) {
.radio ::deep(.el-table__header th.el-table__cell:nth-child(1) .el-checkbox) {
visibility: hidden;
}
</style>
@@ -464,7 +464,7 @@ const onError = (error: any) => {
}
}
:deep(.el-upload--picture-card) {
::deep(.el-upload--picture-card) {
position: relative;
width: v-bind("props.style.width ?? '150px'");
height: v-bind("props.style.height ?? '150px'");
@@ -16,59 +16,51 @@
>
<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>
<!-- 渲染普通列default 插槽须紧凑书写避免空白文本抢占 formatter renderColumnFormatter 注释 -->
<ElTableColumn v-else v-bind="cleanBodyColumnProps(col)">
<template v-if="col.useHeaderSlot && col.prop" #header="headerScope">
<slot
:name="col.headerSlotName || `${col.prop}-header`"
v-bind="{ ...headerScope, prop: col.prop, label: col.label }"
>
{{ col.label }}
</slot>
</template>
<!-- 整段单行插槽内兄弟节点之间的空白文本会让 EP 误判单元格内容 -->
<!-- eslint-disable-next-line vue/max-attributes-per-line, prettier/prettier -->
<template #default="slotScope">
<slot
v-if="col.useSlot && col.prop && shouldRenderSlotScope(slotScope)"
:name="col.slotName || col.prop"
v-bind="{
...slotScope,
prop: col.prop,
value: col.prop ? slotScope.row[col.prop] : undefined,
}"
/>
<TableFormatterOutlet
v-else-if="col.formatter && !col.useSlot && shouldRenderSlotScope(slotScope)"
:column="col"
:record="slotScope.row"
/>
</template>
</ElTableColumn>
</template>
<template v-if="$slots.default" #default><slot /></template>
<template #empty>
<div v-if="loading"></div>
<ElEmpty v-else :description="emptyText" :image-size="120" />
</template>
</ElTable>
</VueDraggable>
<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 v-if="col.useHeaderSlot && col.prop" #header="headerScope">
<slot
:name="col.headerSlotName || `${col.prop}-header`"
v-bind="{ ...headerScope, prop: col.prop, label: col.label }"
>
{{ col.label }}
</slot>
</template>
<template #default="slotScope">
<slot
v-if="col.useSlot && col.prop && shouldRenderSlotScope(slotScope)"
:name="col.slotName || col.prop"
v-bind="{
...slotScope,
prop: col.prop,
value: col.prop ? slotScope.row[col.prop] : undefined,
}"
/>
<TableFormatterOutlet
v-else-if="col.formatter && !col.useSlot && shouldRenderSlotScope(slotScope)"
:column="col"
:record="slotScope.row"
/>
</template>
</ElTableColumn>
</template>
<template v-if="$slots.default" #default><slot /></template>
<template #empty>
<div v-if="loading"></div>
<ElEmpty v-else :description="emptyText" :image-size="120" />
</template>
</ElTable>
</VueDraggable>
</div>
<div
class="pagination custom-pagination"
@@ -112,13 +104,11 @@ import { storeToRefs } from "pinia";
import { ColumnOption } from "@/types";
import { useTableStore } from "@stores/modules/table.store";
import { useCommon } from "@/hooks/core/useCommon";
import { useTableHeight } from "@/hooks/core/useTableHeight";
import { useWindowSize } from "@vueuse/core";
import FaPagination from "@/components/others/fa-pagination/index.vue";
import { VueDraggable } from "vue-draggable-plus";
/** 空状态插画高度(列表页统一常量) */
const ART_TABLE_EMPTY_IMAGE_SIZE = 120;
defineOptions({ name: "FaTable" });
const { width } = useWindowSize();
@@ -281,7 +271,7 @@ useResizeObserver(tableHeaderRef, (entries) => {
const PAGINATION_SPACING = computed(() => (props.showTableHeader ? 6 : 15));
// 使 Hook
const { containerHeight } = useTableHeight({
useTableHeight({
showTableHeader: computed(() => props.showTableHeader),
paginationHeight,
tableHeaderHeight,
@@ -449,7 +439,7 @@ const findTableHeader = () => {
return;
}
const tableHeader = document.getElementById("art-table-header");
const tableHeader = document.getElementById("fa-table-header");
if (tableHeader) {
tableHeaderRef.value = tableHeader;
} else {
@@ -1,4 +1,4 @@
.art-table {
.fa-table {
position: relative;
height: 100%;
@@ -7,7 +7,7 @@
margin-top: 10px;
}
:deep(.el-loading-mask) {
::deep(.el-loading-mask) {
z-index: 100;
background-color: var(--default-box-color) !important;
}
@@ -23,7 +23,7 @@
// 空状态垂直居中
&.is-empty {
:deep(.el-scrollbar__wrap) {
::deep(.el-scrollbar__wrap) {
display: flex;
}
}
@@ -32,7 +32,7 @@
display: flex;
margin-top: 13px;
:deep(.el-select) {
::deep(.el-select) {
width: 102px !important;
}
@@ -51,11 +51,11 @@
// 自定义分页组件样式
&.custom-pagination {
:deep(.el-pagination) {
::deep(.el-pagination) {
.btn-prev,
.btn-next {
background-color: transparent;
border: 1px solid var(--art-gray-300);
border: 1px solid var(--fa-gray-300);
transition: border-color 0.15s;
&:hover:not(.is-disabled) {
@@ -68,7 +68,7 @@
box-sizing: border-box;
font-weight: 400 !important;
background-color: transparent;
border: 1px solid var(--art-gray-300);
border: 1px solid var(--fa-gray-300);
transition: border-color 0.15s;
&.is-active {
@@ -89,7 +89,7 @@
// 移动端分页
@media (width <= 640px) {
:deep(.el-pagination) {
::deep(.el-pagination) {
display: flex;
flex-wrap: wrap;
gap: 15px 0;
@@ -19,6 +19,8 @@
</template>
<script setup lang="ts">
import { computed } from "vue";
import { storeToRefs } from "pinia";
import { useSettingsStore } from "@stores/modules/setting.store";
import { useCeremony } from "@/hooks/core/useCeremony";
import { WEB_LINKS } from "@utils/constants";
@@ -43,6 +43,8 @@
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted, onBeforeUnmount } from "vue";
import { storeToRefs } from "pinia";
import {
useElementSize,
useRafFn,
@@ -51,6 +53,7 @@ import {
useTimeoutFn,
} from "@vueuse/core";
import { useSettingsStore } from "@stores/modules/setting.store";
import FaSvgIcon from "@/components/base/fa-svg-icon/index.vue";
type ThemeType =
| "theme"
@@ -88,7 +88,7 @@ watchEffect(() => {
width: 100%;
height: 100%;
:deep(svg) {
::deep(svg) {
width: 100%;
height: 100%;
}
@@ -108,7 +108,11 @@
</template>
<script setup lang="ts">
import { computed } from "vue";
import { storeToRefs } from "pinia";
import { useI18n } from "vue-i18n";
import FaLogo from "@/components/base/fa-logo/index.vue";
import FaSvgIcon from "@/components/base/fa-svg-icon/index.vue";
import { useSettingsStore } from "@stores/modules/setting.store";
import { useUserStore } from "@stores/modules/user.store";
import { useHeaderBar } from "@/hooks/core/useHeaderBar";
@@ -252,7 +256,7 @@ const changeThemeColor = (color: string) => {
transform: translateY(-2px);
}
.auth-top-bar__action:hover :deep(.fa-svg-icon) {
.auth-top-bar__action:hover ::deep(.fa-svg-icon) {
color: var(--el-color-primary);
}
@@ -310,15 +314,15 @@ const changeThemeColor = (color: string) => {
}
/* 调色盘:图标颜色与当前主题主色一致(含单独悬浮、整块调色区悬浮) */
.palette-btn :deep(.fa-svg-icon) {
.palette-btn ::deep(.fa-svg-icon) {
color: v-bind("themeColorForCss");
}
.auth-top-bar__action.palette-btn:hover :deep(.fa-svg-icon) {
.auth-top-bar__action.palette-btn:hover ::deep(.fa-svg-icon) {
color: v-bind("themeColorForCss");
}
.color-picker-expandable:hover .palette-btn :deep(.fa-svg-icon) {
.color-picker-expandable:hover .palette-btn ::deep(.fa-svg-icon) {
color: v-bind("themeColorForCss");
}
</style>
@@ -78,10 +78,12 @@
</template>
<script setup lang="ts">
import { computed } from "vue";
import AppConfig from "@/config";
import loginIcon from "@imgs/svg/login_icon.svg";
import { useConfigStore } from "@stores/modules/config.store";
import { themeAnimation } from "@utils/ui";
import FaLogo from "@/components/base/fa-logo/index.vue";
import FaThemeSvg from "@/components/theme/fa-theme-svg/index.vue";
defineProps<{
@@ -233,17 +235,11 @@ $bg-mix-light-7: color-mix(in srgb, $primary-light-7 80%, $main-bg);
@if $direction == "up" {
transform: translateY(30px) rotate($rotation);
}
@else if $direction == "down" {
} @else if $direction == "down" {
transform: translateY(-30px) rotate($rotation);
}
@else if $direction == "left" {
} @else if $direction == "left" {
transform: translateX(-30px) rotate($rotation);
}
@else if $direction == "right" {
} @else if $direction == "right" {
transform: translateX(30px) rotate($rotation);
}
}
@@ -253,9 +249,7 @@ $bg-mix-light-7: color-mix(in srgb, $primary-light-7 80%, $main-bg);
@if $direction == "up" or $direction == "down" {
transform: translateY(0) rotate($rotation);
}
@else {
} @else {
transform: translateX(0) rotate($rotation);
}
}
@@ -21,6 +21,8 @@
</template>
<script setup lang="ts">
import FaSvgIcon from "@/components/base/fa-svg-icon/index.vue";
defineOptions({ name: "FaResultPage" });
//
@@ -10,6 +10,8 @@
</template>
<script lang="ts" setup>
import FaSvgIcon from "@/components/base/fa-svg-icon/index.vue";
defineOptions({ name: "FaIconButton" });
interface Props {
+9 -3
View File
@@ -24,13 +24,17 @@ export const globalComponentsConfig: GlobalComponentConfig[] = [
{
name: "设置面板",
key: "settings-panel",
component: defineAsyncComponent(() => import("@/components/layouts/fa-settings-panel/index.vue")),
component: defineAsyncComponent(
() => import("@/components/layouts/fa-settings-panel/index.vue")
),
enabled: true,
},
{
name: "全局搜索",
key: "global-search",
component: defineAsyncComponent(() => import("@/components/layouts/fa-global-search/index.vue")),
component: defineAsyncComponent(
() => import("@/components/layouts/fa-global-search/index.vue")
),
enabled: true,
},
{
@@ -48,7 +52,9 @@ export const globalComponentsConfig: GlobalComponentConfig[] = [
{
name: "礼花效果",
key: "fireworks-effect",
component: defineAsyncComponent(() => import("@/components/layouts/fa-fireworks-effect/index.vue")),
component: defineAsyncComponent(
() => import("@/components/layouts/fa-fireworks-effect/index.vue")
),
enabled: true,
},
{
+1 -1
View File
@@ -13,7 +13,7 @@ const fastEnterConfig: FastEnterConfig = {
{
name: "工作台",
description: "系统概览与数据统计",
icon: "ri:pie-chfa-line",
icon: "ri:pie-chart-line",
iconColor: "#377dff",
enabled: true,
order: 1,
+1 -44
View File
@@ -22,7 +22,7 @@
* - `image` hb sd yd import
* - **Ctrl + Shift + P**Windows / Linux ** + Shift + P**macOS
* 使 `image`
* - `src/layouts/fa-fireworks-effect/index.vue``src/hooks/core/useCeremony.ts`
* - `src/layouts/art-fireworks-effect/index.vue``src/hooks/core/useCeremony.ts`
*
* ##
*
@@ -47,9 +47,6 @@
*/
import { FestivalConfig } from "@/types/config";
import hb from "@imgs/ceremony/hb.png";
import sd from "@imgs/ceremony/sd.png";
import yd from "@imgs/ceremony/yd.png";
export const festivalConfigList: FestivalConfig[] = [
/**
@@ -67,46 +64,6 @@ export const festivalConfigList: FestivalConfig[] = [
'🎉 {{version}}版本正式上线!能力全面提升,配套完整交付方案,助力高效开发与商业落地。 <a href="{{introduceUrl}}" target="_blank" rel="noopener noreferrer">👉 立即体验演示</a>',
},
/**
* + 线 start/end
*/
{
name: "春节祝福",
date: "2026-02-15",
endDate: "2026-02-23",
image: hb,
count: 6,
fireworkInterval: 850,
scrollText:
"🧧 新春快乐!FastAPI Admin 祝您阖家安康、迭代顺利,新的一年代码零告警、上线零事故。",
},
/**
* 5
*/
{
name: "初夏有礼",
date: "2026-05-01",
endDate: "2026-05-31",
image: yd,
count: 4,
fireworkInterval: 1000,
scrollText: "🎆 五月愉快!感谢使用本系统,记得关注备份与安全策略,遇到问题先查文档或联系运维。",
},
/**
* +
*/
{
name: "冬日暖心",
date: "2026-12-20",
endDate: "2026-12-26",
image: sd,
count: 8,
fireworkInterval: 700,
scrollText: "❄️ 岁末将至,敬颂冬绥。感谢一路相伴,祝交付顺利、心情像雪花一样轻盈~",
},
/**
* / import
*
+1 -1
View File
@@ -14,7 +14,7 @@
* ##
*
* 1.
* - src/layouts/fa-settings-panel/widget/SettingActions.vue
* - src/layouts/art-settings-panel/widget/SettingActions.vue
* - src/store/modules/setting.tsStore
* 2. "复制配置"
* 3. src/enums/appEnum.ts
+1 -1
View File
@@ -54,7 +54,7 @@ export const useAuth = () => {
: [];
/**
* v-hasPerm / FaButtonMore
* v-hasPerm / ArtButtonMore
* @param auth permission module_system:role:update
*/
const hasAuth = (auth: string): boolean => {
+1 -1
View File
@@ -14,7 +14,7 @@
*
* ##
*
* **Ctrl+Shift+P** / **+Shift+P** `fa-fireworks-effect`
* **Ctrl+Shift+P** / **+Shift+P** `art-fireworks-effect`
* `src/config/modules/festival.ts`
*
* ## 使
+2 -32
View File
@@ -1,18 +1,5 @@
/**
* useCommon -
*
*
* 便
*
* ##
*
* 1. -
* 2. -
* 3. -
* 4. -
*
* @module useCommon
* @author FastapiAdmin Team
* settings
*/
import { computed } from "vue";
@@ -23,13 +10,9 @@ export function useCommon() {
const menuStore = useMenuStore();
const settingStore = useSettingsStore();
/**
*
* store
*/
const homePath = computed(() => menuStore.getHomePath());
/** 触发 `FaPageContent` 级重建(与布局 settings 联动) */
/** 触发 `ArtPageContent` 级重建(与布局 settings 联动) */
const refresh = () => {
settingStore.reload();
};
@@ -38,10 +21,6 @@ export function useCommon() {
const getMainScrollEl = (): HTMLElement | null =>
document.getElementById("app-scroll-main") ?? document.getElementById("app-main");
/**
*
*
*/
const scrollToTop = () => {
const scrollContainer = getMainScrollEl();
if (scrollContainer) {
@@ -49,10 +28,6 @@ export function useCommon() {
}
};
/**
*
* 使 smooth
*/
const smoothScrollToTop = () => {
const scrollContainer = getMainScrollEl();
if (scrollContainer) {
@@ -63,11 +38,6 @@ export function useCommon() {
}
};
/**
*
* @param top
* @param smooth 使
*/
const scrollTo = (top: number, smooth: boolean = false) => {
const scrollContainer = getMainScrollEl();
if (scrollContainer) {
+1 -1
View File
@@ -82,7 +82,7 @@ export function useHeaderBar() {
return isFeatureEnabled("breadcrumb") && showCrumbs.value;
});
// 全局搜索:新版 FaGlobalSearch 体验更好,显示条件 = 配置开启 ∧ 旧版「菜单搜索」开关
// 全局搜索:新版 ArtGlobalSearch 体验更好,显示条件 = 配置开启 ∧ 旧版「菜单搜索」开关
const shouldShowGlobalSearch = computed(() => {
return isFeatureEnabled("globalSearch") && showMenuSearch.value;
});
+225 -162
View File
@@ -1,23 +1,28 @@
/**
* useTable -
*
*
*
*
* `fetchData` `apiFn` TableCache `getData` / `fetchData`
* `fetchDataQuiet` reject try/catch
*
* ##
*
* - `inFlightDedupePromise` + `inFlightDedupeKey`
* - `globalListNetworkInflight` HTTP
*
* 1. - API
* 2. -
* 3. -
* 4. -
* 5. - 5 ////
* 6. - /
*
* @module useTable
* @author FastapiAdmin Team
* KeepAlive`onDeactivated` `tableViewActive` `fetchData` HTTP
*/
import { ref, reactive, computed, onMounted, onUnmounted, nextTick, readonly } from "vue";
import {
ref,
reactive,
computed,
onMounted,
onUnmounted,
onActivated,
onDeactivated,
nextTick,
readonly,
toRaw,
} from "vue";
import { useWindowSize } from "@vueuse/core";
import type { AxiosResponse } from "axios";
import { useTableColumns } from "./useTableColumns";
@@ -37,7 +42,10 @@ import {
} from "@utils/table";
import { tableConfig } from "@utils/table";
// 类型推导工具类型
/** 跨组件实例:同一 dedupeKey 仅一条进行中的网络请求 */
const globalListNetworkInflight = new Map<string, Promise<TableApiResponse<unknown>>>();
// --- 类型推导(由 apiFn / 响应类型反推记录类型) ---
type InferApiParams<T> = T extends (params: infer P) => any ? P : never;
type UnwrapAxiosResponse<T> = T extends AxiosResponse<infer D> ? D : T;
type InferApiResponse<T> = T extends (params: any) => Promise<infer R>
@@ -60,7 +68,7 @@ type PaginationState = {
total: number;
};
// 优化的配置接口 - 支持自动类型推导
/** 表格 Hook 配置(泛型由 apiFn 推导) */
export interface UseTableConfig<
TApiFn extends (params: any) => Promise<any> = (params: any) => Promise<any>,
TRecord = InferRecordType<InferApiResponse<TApiFn>>,
@@ -131,23 +139,13 @@ export interface UseTableConfig<
};
}
/** 对外入口(实现为内部 `useTableImpl` */
export function useTable<TApiFn extends (params: any) => Promise<any>>(
config: UseTableConfig<TApiFn>
) {
return useTableImpl(config);
}
/**
* useTable - Hook
*
*
* -
* -
* -
* -
* -
* -
*/
function useTableImpl<TApiFn extends (params: any) => Promise<any>>(
config: UseTableConfig<TApiFn>
) {
@@ -216,6 +214,31 @@ function useTableImpl<TApiFn extends (params: any) => Promise<any>>(
// 请求取消控制器
let abortController: AbortController | null = null;
/** 同参且尚未结束的请求共用一个 Promise,避免并发重复打接口 */
let inFlightDedupeKey: string | null = null;
let inFlightDedupePromise: Promise<TableApiResponse<TRecord>> | null = null;
/** KeepAlive 失活时不再发请求(组件侧 cancelRequest 会 abort */
let tableViewActive = true;
/** 稳定序列化请求参,供 in-flight 去重用 */
function stableDedupeKeyFromParams(params: TParams): string {
const normalize = (input: unknown): unknown => {
if (input === null || typeof input !== "object") return input;
if (Array.isArray(input)) return input.map(normalize);
const raw = input as Record<string, unknown>;
const keys = Object.keys(raw).sort();
const out: Record<string, unknown> = {};
for (const k of keys) {
const v = raw[k];
if (v === undefined) continue;
out[k] = normalize(v);
}
return out;
};
return JSON.stringify(normalize(toRaw(params) as unknown));
}
// 缓存清理定时器
let cacheCleanupTimer: NodeJS.Timeout | null = null;
@@ -294,52 +317,122 @@ function useTableImpl<TApiFn extends (params: any) => Promise<any>>(
cacheUpdateTrigger.value++;
};
// 获取数据的核心方法
/**
* `data` / `pagination`
*
*/
function commitNetworkSuccess(
standardResponse: TableApiResponse<TRecord>,
paramsForCache: TParams,
useCacheFlag: boolean
): void {
let tableData = extractTableData(standardResponse);
if (dataTransformer) {
tableData = dataTransformer(tableData);
}
data.value = tableData;
updatePaginationFromResponse(pagination, standardResponse);
const paramsRecord = searchParams as Record<string, unknown>;
if (paramsRecord[pageKey] !== pagination.current) {
paramsRecord[pageKey] = pagination.current;
}
if (paramsRecord[sizeKey] !== pagination.size) {
paramsRecord[sizeKey] = pagination.size;
}
if (useCacheFlag && cache) {
cache.set(paramsForCache, tableData, standardResponse);
cacheUpdateTrigger.value++;
logger.log(`数据已缓存`);
}
loadingState.value = "success";
if (onSuccess) {
onSuccess(tableData, standardResponse);
}
}
/**
* `apiFn`
* Map Promise
*/
const fetchData = async (
params?: Partial<TParams>,
useCache = enableCache
): Promise<TableApiResponse<TRecord>> => {
// 取消上一个请求
let requestParams = Object.assign(
{},
searchParams,
{
[pageKey]: pagination.current,
[sizeKey]: pagination.size,
},
params || {}
) as TParams;
if (excludeParams.length > 0) {
const filteredParams = { ...requestParams };
excludeParams.forEach((key) => {
delete (filteredParams as Record<string, unknown>)[key];
});
requestParams = filteredParams as TParams;
}
const dedupeKey = stableDedupeKeyFromParams(requestParams);
if (inFlightDedupePromise !== null && inFlightDedupeKey === dedupeKey) {
logger.log("合并同参进行中的列表请求");
return inFlightDedupePromise;
}
const sharedGlobal = globalListNetworkInflight.get(dedupeKey);
if (sharedGlobal) {
loadingState.value = "loading";
error.value = null;
try {
const standardResponse = (await sharedGlobal) as TableApiResponse<TRecord>;
commitNetworkSuccess(standardResponse, requestParams, useCache);
return standardResponse;
} catch (err) {
if (err instanceof Error && err.message === "请求已取消") {
loadingState.value = "idle";
return { records: [], total: 0, current: 1, size: 10 };
}
loadingState.value = "error";
data.value = [];
const tableError = handleError(err, "获取表格数据失败");
throw tableError;
}
}
if (!tableViewActive) {
return {
records: [...data.value],
total: pagination.total,
current: pagination.current,
size: pagination.size,
} as TableApiResponse<TRecord>;
}
if (abortController) {
abortController.abort();
}
// 创建新的取消控制器
const currentController = new AbortController();
abortController = currentController;
// 状态机:进入 loading 状态
loadingState.value = "loading";
error.value = null;
try {
let requestParams = Object.assign(
{},
searchParams,
{
[pageKey]: pagination.current,
[sizeKey]: pagination.size,
},
params || {}
) as TParams;
// 剔除不需要的参数
if (excludeParams.length > 0) {
const filteredParams = { ...requestParams };
excludeParams.forEach((key) => {
delete (filteredParams as Record<string, unknown>)[key];
});
requestParams = filteredParams as TParams;
}
// 检查缓存
if (useCache && cache) {
const cachedItem = cache.get(requestParams);
if (cachedItem) {
data.value = cachedItem.data;
updatePaginationFromResponse(pagination, cachedItem.response);
// 修复:避免重复设置相同的值,防止响应式循环更新
const paramsRecord = searchParams as Record<string, unknown>;
if (paramsRecord[pageKey] !== pagination.current) {
paramsRecord[pageKey] = pagination.current;
@@ -348,10 +441,8 @@ function useTableImpl<TApiFn extends (params: any) => Promise<any>>(
paramsRecord[sizeKey] = pagination.size;
}
// 状态机:缓存命中,进入 success 状态
loadingState.value = "success";
// 缓存命中时触发专门的回调,而不是 onSuccess
if (onCacheHit) {
onCacheHit(cachedItem.data, cachedItem.response);
}
@@ -361,100 +452,87 @@ function useTableImpl<TApiFn extends (params: any) => Promise<any>>(
}
}
const response = await apiFn(requestParams);
inFlightDedupeKey = dedupeKey;
// 检查请求是否被取消
if (currentController.signal.aborted) {
throw new Error("请求已取消");
const networkPromise = (async (): Promise<TableApiResponse<TRecord>> => {
try {
logger.log("HTTP 请求", dedupeKey);
const response = await apiFn(requestParams);
if (currentController.signal.aborted) {
throw new Error("请求已取消");
}
return responseAdapter(response);
} finally {
if (inFlightDedupeKey === dedupeKey) {
inFlightDedupeKey = null;
inFlightDedupePromise = null;
}
if (abortController === currentController) {
abortController = null;
}
}
})();
globalListNetworkInflight.set(
dedupeKey,
networkPromise as Promise<TableApiResponse<unknown>>
);
networkPromise.finally(() => {
globalListNetworkInflight.delete(dedupeKey);
});
inFlightDedupePromise = networkPromise;
try {
const standardResponse = await networkPromise;
commitNetworkSuccess(standardResponse, requestParams, useCache);
return standardResponse;
} catch (err) {
if (err instanceof Error && err.message === "请求已取消") {
loadingState.value = "idle";
return { records: [], total: 0, current: 1, size: 10 };
}
loadingState.value = "error";
data.value = [];
const tableError = handleError(err, "获取表格数据失败");
throw tableError;
}
// 使用响应适配器转换为标准格式
const standardResponse = responseAdapter(response);
// 处理响应数据
let tableData = extractTableData(standardResponse);
// 应用数据转换函数
if (dataTransformer) {
tableData = dataTransformer(tableData);
}
// 更新状态
data.value = tableData;
updatePaginationFromResponse(pagination, standardResponse);
// 修复:避免重复设置相同的值,防止响应式循环更新
const paramsRecord = searchParams as Record<string, unknown>;
if (paramsRecord[pageKey] !== pagination.current) {
paramsRecord[pageKey] = pagination.current;
}
if (paramsRecord[sizeKey] !== pagination.size) {
paramsRecord[sizeKey] = pagination.size;
}
// 缓存数据
if (useCache && cache) {
cache.set(requestParams, tableData, standardResponse);
// 手动触发缓存状态更新
cacheUpdateTrigger.value++;
logger.log(`数据已缓存`);
}
// 状态机:请求成功,进入 success 状态
loadingState.value = "success";
// 成功回调
if (onSuccess) {
onSuccess(tableData, standardResponse);
}
return standardResponse;
} catch (err) {
if (err instanceof Error && err.message === "请求已取消") {
// 请求被取消,回到 idle 状态
loadingState.value = "idle";
return { records: [], total: 0, current: 1, size: 10 };
}
// 状态机:请求失败,进入 error 状态
loadingState.value = "error";
data.value = [];
const tableError = handleError(err, "获取表格数据失败");
throw tableError;
} finally {
// 只有当前控制器是活跃的才清空
if (abortController === currentController) {
abortController = null;
}
}
};
// 获取数据 (保持当前页)
const getData = async (params?: Partial<TParams>): Promise<TableApiResponse<TRecord> | void> => {
/**
* reject `undefined` fetchData / handleError
*/
async function fetchDataQuiet(
params?: Partial<TParams>,
useCache = enableCache
): Promise<TableApiResponse<TRecord> | void> {
try {
return await fetchData(params);
return await fetchData(params, useCache);
} catch {
// 错误已在 fetchData 中处理
return Promise.resolve();
return undefined;
}
};
}
// 分页获取数据 (重置到第一页) - 专门用于搜索场景
/** 按当前分页拉取(默认走缓存策略 enableCache */
const getData = (params?: Partial<TParams>) => fetchDataQuiet(params);
/** 搜索场景:回到第一页、清当前条件缓存,再拉取(禁用缓存) */
const getDataByPage = async (
params?: Partial<TParams>
): Promise<TableApiResponse<TRecord> | void> => {
pagination.current = 1;
(searchParams as Record<string, unknown>)[pageKey] = 1;
// 搜索时清空当前搜索条件的缓存,确保获取最新数据
clearCache(CacheInvalidationStrategy.CLEAR_CURRENT, "搜索数据");
try {
return await fetchData(params, false); // 搜索时不使用缓存
} catch {
// 错误已在 fetchData 中处理
return Promise.resolve();
}
return fetchDataQuiet(params, false);
};
// 智能防抖搜索函数
@@ -524,10 +602,9 @@ function useTableImpl<TApiFn extends (params: any) => Promise<any>>(
pagination.size = currentSize;
};
// 防重复调用的标志
/** 防止分页页码变更回调重入(EP 或联动可能短时触发多次) */
let isCurrentChanging = false;
// 处理分页大小变化
const handleSizeChange = async (newSize: number): Promise<void> => {
if (newSize <= 0) return;
@@ -544,16 +621,13 @@ function useTableImpl<TApiFn extends (params: any) => Promise<any>>(
await getData();
};
// 处理当前页变化
const handleCurrentChange = async (newCurrent: number): Promise<void> => {
if (newCurrent <= 0) return;
// 修复:防止重复调用
if (isCurrentChanging) {
return;
}
// 修复:如果当前页没有变化,不需要重新请求
if (pagination.current === newCurrent) {
logger.log("分页页码未变化,跳过请求");
return;
@@ -562,10 +636,8 @@ function useTableImpl<TApiFn extends (params: any) => Promise<any>>(
try {
isCurrentChanging = true;
// 修复:只更新必要的状态
const paramsRecord = searchParams as Record<string, unknown>;
pagination.current = newCurrent;
// 只有当 searchParams 的分页字段与新值不同时才更新
if (paramsRecord[pageKey] !== newCurrent) {
paramsRecord[pageKey] = newCurrent;
}
@@ -576,9 +648,8 @@ function useTableImpl<TApiFn extends (params: any) => Promise<any>>(
}
};
// 针对不同业务场景的刷新方法
// --- 刷新策略(命名表达业务语义,便于页面按钮选用) ---
// 新增后刷新:回到第一页并清空分页缓存(适用于新增数据后)
const refreshCreate = async (): Promise<void> => {
debouncedGetDataByPage.cancel();
pagination.current = 1;
@@ -587,13 +658,11 @@ function useTableImpl<TApiFn extends (params: any) => Promise<any>>(
await getData();
};
// 更新后刷新:保持当前页,仅清空当前搜索缓存(适用于更新数据后)
const refreshUpdate = async (): Promise<void> => {
clearCache(CacheInvalidationStrategy.CLEAR_CURRENT, "编辑数据");
await getData();
};
// 删除后刷新:智能处理页码,避免空页面(适用于删除数据后)
const refreshRemove = async (): Promise<void> => {
const { current } = pagination;
@@ -601,7 +670,6 @@ function useTableImpl<TApiFn extends (params: any) => Promise<any>>(
clearCache(CacheInvalidationStrategy.CLEAR_CURRENT, "删除数据");
await getData();
// 如果当前页为空且不是第一页,回到上一页
if (data.value.length === 0 && current > 1) {
pagination.current = current - 1;
(searchParams as Record<string, unknown>)[pageKey] = current - 1;
@@ -609,14 +677,12 @@ function useTableImpl<TApiFn extends (params: any) => Promise<any>>(
}
};
// 全量刷新:清空所有缓存,重新获取数据(适用于手动刷新按钮)
const refreshData = async (): Promise<void> => {
debouncedGetDataByPage.cancel();
clearCache(CacheInvalidationStrategy.CLEAR_ALL, "手动刷新");
await getData();
};
// 轻量刷新:仅清空当前搜索条件的缓存,保持分页状态(适用于定时刷新)
const refreshSoft = async (): Promise<void> => {
clearCache(CacheInvalidationStrategy.CLEAR_CURRENT, "软刷新");
await getData();
@@ -630,6 +696,15 @@ function useTableImpl<TApiFn extends (params: any) => Promise<any>>(
debouncedGetDataByPage.cancel();
};
onActivated(() => {
tableViewActive = true;
});
onDeactivated(() => {
tableViewActive = false;
cancelRequest();
});
// 清空数据
const clearData = (): void => {
data.value = [];
@@ -660,14 +735,12 @@ function useTableImpl<TApiFn extends (params: any) => Promise<any>>(
}, cacheTime / 2); // 每半个缓存周期清理一次
}
// 挂载时自动加载数据
if (immediate) {
onMounted(async () => {
await getData();
});
}
// 组件卸载时彻底清理
onUnmounted(() => {
cancelRequest();
if (cache) {
@@ -678,9 +751,8 @@ function useTableImpl<TApiFn extends (params: any) => Promise<any>>(
}
});
// 优化的返回值结构
return {
// 数据相关
// --- 数据 ---
/** 表格数据 */
data,
/** 数据加载状态 */
@@ -692,7 +764,7 @@ function useTableImpl<TApiFn extends (params: any) => Promise<any>>(
/** 是否有数据 */
hasData,
// 分页相关
// --- 分页 ---
/** 分页状态信息 */
pagination: readonly(pagination),
/** 移动端分页配置 */
@@ -702,7 +774,7 @@ function useTableImpl<TApiFn extends (params: any) => Promise<any>>(
/** 当前页变化处理 */
handleCurrentChange,
// 搜索相关 - 统一前缀
// --- 查询条件 ---
/** 搜索参数 */
searchParams,
/** 替换搜索参数(适用于表单查询,避免旧字段残留) */
@@ -710,17 +782,16 @@ function useTableImpl<TApiFn extends (params: any) => Promise<any>>(
/** 重置搜索参数 */
resetSearchParams,
// 数据操作 - 更明确的操作意图
/** 加载数据 */
/** 当前页加载(错误已内部处理) */
fetchData: getData,
/** 获取数据 */
/** 回到第一页并查询(搜索 / 重置页码场景) */
getData: getDataByPage,
/** 获取数据(防抖) */
getDataDebounced: debouncedGetDataByPage,
/** 清空数据 */
clearData,
// 刷新策略
// --- 刷新 ---
/** 全量刷新:清空所有缓存,重新获取数据(适用于手动刷新按钮) */
refreshData,
/** 轻量刷新:仅清空当前搜索条件的缓存,保持分页状态(适用于定时刷新) */
@@ -732,24 +803,17 @@ function useTableImpl<TApiFn extends (params: any) => Promise<any>>(
/** 删除后刷新:智能处理页码,避免空页面(适用于删除数据后) */
refreshRemove,
// 缓存控制
/** 缓存统计信息 */
// --- 缓存(策略见 CacheInvalidationStrategy ---
cacheInfo,
/** 清除缓存,根据不同的业务场景选择性地清理缓存: */
clearCache,
// 支持4种清理策略
// clearCache(CacheInvalidationStrategy.CLEAR_ALL, '手动刷新') // 清空所有缓存
// clearCache(CacheInvalidationStrategy.CLEAR_CURRENT, '搜索数据') // 只清空当前搜索条件的缓存
// clearCache(CacheInvalidationStrategy.CLEAR_PAGINATION, '新增数据') // 清空分页相关缓存
// clearCache(CacheInvalidationStrategy.KEEP_ALL, '保持缓存') // 不清理任何缓存
/** 清理已过期的缓存条目,释放内存空间 */
clearExpiredCache,
// 请求控制
// --- 请求 ---
/** 取消当前请求 */
cancelRequest,
// 列配置 (如果提供了 columnsFactory)
// --- 列配置(仅传入 columnsFactory 时展开) ---
...(columnConfig && {
/** 表格列配置 */
columns,
@@ -777,7 +841,6 @@ function useTableImpl<TApiFn extends (params: any) => Promise<any>>(
};
}
// 重新导出类型和枚举,方便使用
export { CacheInvalidationStrategy } from "@utils/table";
export type { ApiResponse, CacheItem } from "@utils/table";
export type { BaseRequestParams, TableError } from "@utils/table";
+4 -46
View File
@@ -1,72 +1,30 @@
/**
* useTableColumns -
*
*
* useTable 使
*
* ##
*
* 1. - /
* 2. -
* 3. -
* 4. - selectionexpandindex
* 5. -
*
* ## 使
*
* ```typescript
* const { columns, columnChecks, toggleColumn, reorderColumns } = useTableColumns(() => [
* { prop: 'name', label: '姓名', visible: true },
* { prop: 'email', label: '邮箱', visible: true },
* { prop: 'status', label: '状态', visible: false }
* ])
*
* // 切换列显示
* toggleColumn('email', false)
*
* // 重新排序
* reorderColumns(0, 2)
* ```
*
* @module useTableColumns
* @author FastapiAdmin Team
* `useTable` `columnsFactory`
* `getColumnVisibility` / `getColumnChecks` visible/checked
*/
import { ref, computed, watch } from "vue";
import { $t } from "@/locales";
import type { ColumnOption } from "@/types/component";
/**
*
*/
/** selection / expand / index 等占位 prop,避免与业务列冲突 */
const SPECIAL_COLUMNS: Record<string, { prop: string; label: string }> = {
selection: { prop: "__selection__", label: $t("table.column.selection") },
expand: { prop: "__expand__", label: $t("table.column.expand") },
index: { prop: "__index__", label: $t("table.column.index") },
};
/**
*
*/
export const getColumnKey = <T>(col: ColumnOption<T>) =>
SPECIAL_COLUMNS[col.type as keyof typeof SPECIAL_COLUMNS]?.prop ?? (col.prop as string);
/**
*
* 使 visible 使 checked
*/
/** `visible` 优先;否则回退 `checked`,默认展示 */
export const getColumnVisibility = <T>(col: ColumnOption<T>): boolean => {
// visible 优先级高于 checked
if (col.visible !== undefined) {
return col.visible;
}
// 如果 visible 未定义,使用 checked,默认为 true
return col.checked ?? true;
};
/**
*
*/
export const getColumnChecks = <T>(columns: ColumnOption<T>[]) =>
columns.map((col) => {
const special = col.type && SPECIAL_COLUMNS[col.type];
+1
View File
@@ -0,0 +1 @@
../components/layouts/index.vue
+9 -1
View File
@@ -14,6 +14,14 @@
"requestFailed": "Request failed",
"requestConfigError": "Request configuration error"
},
"manualPage": {
"title": "User manual",
"intro": "Watch the walkthrough video and browse the built-in checklist mapped to implemented UI and permission codes (coverage / completeness, not a quality score). Replace the demo video URL with your own CDN in production.",
"videoTab": "Video",
"manualTab": "Acceptance checklist",
"videoHint": "Pause, change speed, or go fullscreen anytime.",
"manualHint": "Use the TOC, filter, and anchors. “Functional completeness” items mirror implemented search bars, toolbars, row actions, and dialogs for gap checking."
},
"topBar": {
"search": {
"title": "Search"
@@ -542,7 +550,7 @@
"ecommerce": "Ecommerce",
"map": "Map",
"pricing": "Pricing",
"tutorial": "Tutorial"
"tutorial": "User manual"
},
"article": {
"title": "Article",
+9 -1
View File
@@ -14,6 +14,14 @@
"requestFailed": "请求失败",
"requestConfigError": "请求配置错误"
},
"manualPage": {
"title": "操作手册",
"intro": "视频演示常用操作流程;「功能验收」正文按模块列出已实现界面与权限,用于核对功能是否覆盖完整(非评价好坏)。正式环境可将演示视频替换为自有 CDN / 静态资源地址。",
"videoTab": "视频演示",
"manualTab": "功能验收",
"videoHint": "可随时暂停、调速与全屏观看。",
"manualHint": "支持目录锚点与筛选;正文「功能完整性验收」与代码已实现的检索、工具栏、行操作、弹层等逐项对应,便于漏项检查。"
},
"topBar": {
"search": {
"title": "搜索"
@@ -542,7 +550,7 @@
"ecommerce": "电子商务",
"map": "地图",
"pricing": "定价",
"tutorial": "操作教程"
"tutorial": "操作手册"
},
"article": {
"title": "文章管理",
+3 -2
View File
@@ -4,13 +4,14 @@ import "@styles/core/tailwind.css";
import "@styles/index.scss";
import "element-plus/theme-chalk/dark/css-vars.css";
import "animate.css";
import { printConsoleBanner } from "@utils/sys";
import { initPlugins } from "@/plugins";
import { useConfigStore } from "./store/modules/config.store";
document.addEventListener("touchstart", function () {}, { passive: false });
const app = createApp(App);
printConsoleBanner();
initPlugins(app);
app.mount("#app");
@@ -18,7 +19,7 @@ app.mount("#app");
const setTitleAndFavicon = async () => {
try {
const configStore = useConfigStore();
await configStore.getConfig(true);
await configStore.getConfig();
const webTitle = configStore.configData.sys_web_title?.config_value;
const webFavicon = configStore.configData.sys_web_favicon?.config_value;
+1 -1
View File
@@ -150,7 +150,7 @@ export const ArticleList = [
create_time: "2023-04-01T00:00:00.000Z",
home_img: cover8,
brief:
"原理是改变 css 变量 + window.matchMedia 来监听系统主题变,从而实现点击改变主题和监听系统主题变化1、首先定义 css 全局变量创建 variables.scss 文件light color(浅色模式)定义浅色模式下 css 主题变量dark color(深色模式)定义深色模式下 css 主题变量// css全局变量\n:root {\n // 文字大小\n --fa-fo",
"原理是改变 css 变量 + window.matchMedia 来监听系统主题变,从而实现点击改变主题和监听系统主题变化1、首先定义 css 全局变量创建 variables.scss 文件light color(浅色模式)定义浅色模式下 css 主题变量dark color(深色模式)定义深色模式下 css 主题变量// css全局变量\n:root {\n // 文字大小\n --art-fo",
type_name: "CSS",
html_content: "",
},
+12 -12
View File
@@ -65,7 +65,7 @@ export const upgradeLogList = ref<UpgradeLog[]>([
"性能提升:优化核心代码逻辑,提升系统运行效率",
"设计系统:重构颜色体系,统一 UI 视觉规范,提升界面一致性",
"菜单优化:细化菜单样式,优化交互体验与视觉呈现",
"组件重构:重构 FaTextScroll 组件,提升性能与可维护性",
"组件重构:重构 ArtTextScroll 组件,提升性能与可维护性",
"问题修复:修复 FaForm、FaSearchBar 自定义组件渲染异常",
"功能增强:FaForm、FaSearchBar 新增 render 属性,支持自定义组件渲染",
"功能增强:useTable hooks 新增 visible 属性,用于控制列默认是否显示",
@@ -164,7 +164,7 @@ export const upgradeLogList = ref<UpgradeLog[]>([
"优化 FaTable 顶部按钮换行无法自适应问题(示例:功能示例 / 左右布局表格)",
"FaTable 分页组件选中样式优化",
"FaTable 空状态高度默认撑满",
"FaButtonMore 组件新增图标、颜色配置",
"ArtButtonMore 组件新增图标、颜色配置",
"FaTableHeader 新增搜索按钮,用于控制顶部搜索栏的显示与隐藏",
"FaSearchBar label 为空时不占空间",
"表格操作栏拖拽禁止固定列拖拽",
@@ -195,7 +195,7 @@ export const upgradeLogList = ref<UpgradeLog[]>([
"修复多个富文本编辑器图标不统一问题",
"优化颜色选择器圆角",
"el-radio、el-checkbox 统一大小",
"fa-stats-card 新增小数位、分隔符配置",
"art-stats-card 新增小数位、分隔符配置",
"路由配置示例优化",
"高级表格新增自定义获取数据示例(等待其他请求完成后执行 useTable 数据获取)",
"useTable 新增 excludeParams,用于排除某些参数不参与请求",
@@ -203,7 +203,7 @@ export const upgradeLogList = ref<UpgradeLog[]>([
"本地开发跨域配置优化",
"修复 useTable 删除最后一整页数据没有返回上一页的问题",
"修复 echarts 图表数据初始化、更新数据浏览器报错",
"删除 fa-chfa-empty 组件",
"删除 art-chart-empty 组件",
"新增 FaSearchBar 组件示例",
"网络请求支持 http 状态码为 401 时退出登录",
"优化网络请求退出登录多次提示问题",
@@ -286,7 +286,7 @@ export const upgradeLogList = ref<UpgradeLog[]>([
date: "2025-07-06",
remark: "建议升级,带来更高效、更智能的表格开发体验",
detail: [
"重构 FaTable、FaTableHeader、FaNotification 组件",
"重构 FaTable、FaTableHeader、ArtNotification 组件",
"新增 useTable hooks 表格封装,支持数据获取、转换、响应适配、智能缓存(基于 LRU 算法)、错误处理、列配置与插槽、分页控制、刷新策略等核心功能,全面提升开发效率与用户体验",
"修复菜单管理搜索直接修改 pinia 数据的问题",
"移除 CountTo 插件,替换为 FaCountTo 组件",
@@ -337,10 +337,10 @@ export const upgradeLogList = ref<UpgradeLog[]>([
title: "组件重构与性能优化",
date: "2025-06-23",
detail: [
"重构 components/forms 文件夹下的表单相关组件,提升可维护性与一致性",
"重构 FaBreadcrumb 面包屑导航组件,优化逻辑结构与样式",
"优化 FaChatWindow 与 FaFastEnter 组件代码,提升可读性与性能",
"重构 FaFireworksEffect 烟花效果组件,显著提升渲染性能与动画流畅度",
"重构 components/core/forms 文件夹下的表单相关组件,提升可维护性与一致性",
"重构 ArtBreadcrumb 面包屑导航组件,优化逻辑结构与样式",
"优化 ArtChatWindow 与 ArtFastEnter 组件代码,提升可读性与性能",
"重构 ArtFireworksEffect 烟花效果组件,显著提升渲染性能与动画流畅度",
"README 文档新增官方网站链接,便于用户查看项目文档",
],
},
@@ -359,8 +359,8 @@ export const upgradeLogList = ref<UpgradeLog[]>([
date: "2025-06-18",
detail: [
"FaMenuRight 组件重构",
"FaWatermark 增加类型注释",
"components/cards 下面的组件重构,代码优化",
"ArtWatermark 增加类型注释",
"components/core/cards 下面的组件重构,代码优化",
],
},
{
@@ -369,7 +369,7 @@ export const upgradeLogList = ref<UpgradeLog[]>([
date: "2025-06-18",
detail: [
"FaResultPage 组件重构",
"FaTextScroll 组件代码优化",
"ArtTextScroll 组件代码优化",
"FaException 组件增加类型提示",
"FaCutterImg 组件样式优化、增加类型定义",
"FaVideoPlayer 组件增加类型定义",
-3
View File
@@ -19,11 +19,8 @@ import { initCodeMirror } from "./codemirror";
import { initElementPlus } from "./element-plus";
import { initElIcons } from "./icons";
import { initTerminal } from "./terminal";
import { printConsoleBanner } from "@utils/sys";
export function initPlugins(app: App<Element>): void {
// 打印欢迎使用信息
printConsoleBanner();
initElIcons(app);
initStore(app);
initRouter(app);
+15 -13
View File
@@ -1,14 +1,9 @@
/**
* AppRouteRecord
*
* @module router/MenuProcessor
*/
import type { AppRouteRecord, RouteMeta } from "@/types/router";
import type { UserInfo } from "@/api/module_system/user";
import type { MenuTable } from "@/api/module_system/menu";
import { useUserStore } from "@stores/modules/user.store";
import { useAppMode } from "@/hooks/core/useAppMode";
import MenuAPI from "@/api/module_system/menu";
import {
mergeAppRouteRecords,
ROUTE_COMPONENT_LAYOUT,
@@ -17,6 +12,11 @@ import {
import { formatMenuTitle } from "@utils";
import { MenuTypeEnum } from "@/enums/system/menu.enum";
/**
* `AppRouteRecord` `MenuTable`
* `getMenuList` `useAppMode` meta keep_alive
*/
/** 前端模式并入菜单的内置路由(扩展点,默认空) */
export const builtinFrontendRoutes: AppRouteRecord[] = [];
@@ -122,6 +122,7 @@ function mapMenuNode(item: MenuTable, depth = 0): AppRouteRecord {
title: item.title ?? "",
icon: item.icon || undefined,
hidden: !!item.hidden,
/** 与菜单 API `keep_alive` 一致;缺省 true 仅在后端未下发该字段时兜底 */
keepAlive: item.keep_alive ?? true,
affix: !!item.affix,
fixedTab: !!item.affix,
@@ -148,9 +149,6 @@ function backendMenusToAppRoutes(menus: MenuTable[]): AppRouteRecord[] {
}
export class MenuProcessor {
/**
*
*/
async getMenuList(): Promise<AppRouteRecord[]> {
const { isFrontendMode, isMixedMenuMode } = useAppMode();
@@ -212,11 +210,15 @@ export class MenuProcessor {
return this.filterEmptyMenus(merged);
}
/** 优先用用户信息里附带的 `menus`,与守卫拉用户信息顺序一致,避免重复打菜单树接口 */
private async processBackendMenu(): Promise<AppRouteRecord[]> {
const response = await MenuAPI.listMenu({ menu_client: "pc" });
const list = response.data.data || [];
const routes = backendMenusToAppRoutes(list);
return this.filterEmptyMenus(routes);
const userStore = useUserStore();
const fromUser = userStore.routeList;
if (Array.isArray(fromUser) && fromUser.length > 0) {
const routes = backendMenusToAppRoutes(fromUser);
return this.filterEmptyMenus(routes);
}
return [];
}
private filterMenuByRoles(menu: AppRouteRecord[], roleCodes: string[]): AppRouteRecord[] {
+14 -4
View File
@@ -1,3 +1,6 @@
/**
* NProgress beforeEach loading
*/
import { nextTick } from "vue";
import { useSettingsStore } from "@stores/modules/setting.store";
import { Router } from "vue-router";
@@ -6,24 +9,31 @@ import { useCommon } from "@/hooks/core/useCommon";
import { loadingService } from "@utils/ui";
import { getPendingLoading, resetPendingLoading } from "./beforeEach";
/** 路由全局后置守卫 */
/** 防止重复注册 afterEach(与 beforeEach 同理) */
let afterEachGuardRegistered = false;
export function setupAfterEachGuard(router: Router) {
if (afterEachGuardRegistered) {
if (import.meta.env.DEV) {
console.warn("[Router] setupAfterEachGuard 已注册,跳过重复调用");
}
return;
}
afterEachGuardRegistered = true;
const { scrollToTop } = useCommon();
router.afterEach(() => {
scrollToTop();
// 关闭进度条
const settingStore = useSettingsStore();
if (settingStore.showNprogress) {
NProgress.done();
// 确保进度条完全移除,避免残影
setTimeout(() => {
NProgress.remove();
}, 600);
}
// 关闭 loading 效果
if (getPendingLoading()) {
nextTick(() => {
loadingService.hideLoading();
+22 -83
View File
@@ -1,39 +1,6 @@
/**
*
*
*
*
* ##
*
* -
* -
* - /
* -
* -
* -
* -
* -
* -
*
* ## 使
*
* -
* -
* -
* - 访
* -
*
* ##
*
* 1.
* 2. 访
* 3.
* 4.
* 5.
* 6. 404
*
* @module router/beforeEach
* @author FastapiAdmin Team
*
* `setupBeforeEachGuard` `handleRouteGuard`
*/
import type { AppRouteRecord } from "@/types/router";
import type { Router, RouteLocationNormalized, NavigationGuardNext } from "vue-router";
@@ -53,56 +20,50 @@ import { ApiStatus, isHttpError } from "@utils/http";
import { RouteRegistry } from "./dynamicRoutes";
import { MenuProcessor } from "./MenuProcessor";
// 路由注册器实例
let routeRegistry: RouteRegistry | null = null;
// --- 模块级单例与守卫状态 ---
// 菜单处理器实例
let routeRegistry: RouteRegistry | null = null;
const menuProcessor = new MenuProcessor();
// 跟踪是否需要关闭 loading
/** 供 afterEach 关闭全局 loading */
let pendingLoading = false;
// 路由初始化失败标记,防止死循环
// 一旦设置为 true,只有刷新页面或重新登录才能重置
/** 动态路由拉取失败后为 true,避免反复请求造成死循环 */
let routeInitFailed = false;
// 路由初始化进行中标记,防止并发请求
/** 并发导航时只允许一路执行动态路由初始化 */
let routeInitInProgress = false;
/**
* pendingLoading
*/
export function getPendingLoading(): boolean {
return pendingLoading;
}
/**
* pendingLoading
*/
export function resetPendingLoading(): void {
pendingLoading = false;
}
/**
*
*/
export function getRouteInitFailed(): boolean {
return routeInitFailed;
}
/**
*
*/
/** 重新登录等场景重置初始化标记 */
export function resetRouteInitState(): void {
routeInitFailed = false;
routeInitInProgress = false;
}
/**
*
*/
/** 防止 dev/HMR 或异常重复 init 导致多个 beforeEach 叠加(导航副作用与请求会成倍增长) */
let beforeEachGuardRegistered = false;
export function setupBeforeEachGuard(router: Router): void {
// 初始化路由注册器
if (beforeEachGuardRegistered) {
if (import.meta.env.DEV) {
console.warn("[Router] setupBeforeEachGuard 已注册,跳过重复调用");
}
return;
}
beforeEachGuardRegistered = true;
routeRegistry = new RouteRegistry(router);
router.beforeEach(
@@ -122,9 +83,6 @@ export function setupBeforeEachGuard(router: Router): void {
);
}
/**
* loading
*/
function closeLoading(): void {
if (pendingLoading) {
nextTick(() => {
@@ -147,50 +105,40 @@ function applySafeTitleFromQuery(to: RouteLocationNormalized): void {
}
}
/**
*
*/
async function handleRouteGuard(
to: RouteLocationNormalized,
from: RouteLocationNormalized,
next: NavigationGuardNext,
router: Router
): Promise<void> {
// 顺序:登录 → 动态路由初始化失败兜底 → 动态路由注册 → 根路径 → 已匹配页 → 404
const settingStore = useSettingsStore();
const userStore = useUserStore();
// 启动进度条
if (settingStore.showNprogress) {
NProgress.start();
}
// 1. 检查登录状态
if (!handleLoginStatus(to, userStore, next)) {
return;
}
// 2. 检查路由初始化是否已失败(防止死循环)
if (routeInitFailed) {
// 已经失败过,直接放行到错误页面,不再重试
if (to.matched.length > 0) {
next();
} else {
// 未匹配到路由,跳转到 500 页面
next({ name: "500", replace: true });
}
return;
}
// 3. 处理动态路由注册
const menuStore = useMenuStore();
/** 未注册动态路由,或菜单已被清空(登出延迟 reset 与再登录竞态下可能出现「已注册但 menuList 为空」) */
const shouldInitRoutes =
userStore.isLogin && (!routeRegistry?.isRegistered() || menuStore.menuList.length === 0);
if (shouldInitRoutes) {
// 防止并发请求(快速连续导航场景)
if (routeInitInProgress) {
// 正在初始化中,等待完成后重新导航
next(false);
return;
}
@@ -198,12 +146,10 @@ async function handleRouteGuard(
return;
}
// 4. 处理根路径重定向
if (handleRootPathRedirect(to, next)) {
return;
}
// 5. 处理已匹配的路由
if (to.matched.length > 0) {
applySafeTitleFromQuery(to);
setWorktab(to);
@@ -212,27 +158,20 @@ async function handleRouteGuard(
return;
}
// 6. 未匹配到路由,跳转到 404
next({ name: "404" });
}
/**
*
* @returns true false
*/
/** @returns 是否继续守卫;false 表示已 `next` 跳转 */
function handleLoginStatus(
to: RouteLocationNormalized,
userStore: ReturnType<typeof useUserStore>,
next: NavigationGuardNext
): boolean {
// 已登录 / 登录相关路由 / 明确配置的匿名白名单,放行
// 注意:不能使用「整条 staticRoutes 都算静态」——否则 `/`、`/` 重定向到的业务页也会被当成免登录,
// 未登录用户会先被放过再跳首页(见 issue:打开站点先进首页而非登录页)。
// 勿把「整条 staticRoutes」当匿名:否则 `/` 重定向到的业务页会被误放行(未登录先进首页)
if (userStore.isLogin || isLoginRoute(to) || isAnonymousPublicPath(to.path)) {
return true;
}
// 未登录且访问需要权限的页面,跳转到登录页并携带 redirect 参数
userStore.resetAllState();
next({
name: "Login",
+8 -7
View File
@@ -1,6 +1,6 @@
/**
* RootLayout
* web permission / transform
* `AppRouteRecord` `RouteTransformer` import.glob `router.addRoute(RootLayout)`
* RouteValidatorComponentLoaderRouteRegistry
*/
import { defineComponent, h, onMounted, ref } from "vue";
import type { RouteRecordRaw, Router } from "vue-router";
@@ -20,6 +20,7 @@ export interface ValidationResult {
warnings: string[];
}
/** 菜单注册前校验:重名、缺少 component、深层误用 layout 占位等 */
export class RouteValidator {
private warnedRoutes = new Set<string>();
@@ -150,7 +151,7 @@ export class RouteValidator {
}
}
// ----------------------------------------------------------------------------- ComponentLoader
// --- ComponentLoader:菜单字符串路径 → views 懒加载 ---
export class ComponentLoader {
private modules: Record<string, () => Promise<any>>;
@@ -186,7 +187,7 @@ export class ComponentLoader {
}
loadLayout(): () => Promise<any> {
return () => import("@/components/layouts/index.vue");
return () => import("@/layouts/index.vue");
}
loadIframe(): () => Promise<any> {
@@ -249,7 +250,7 @@ export class ComponentLoader {
}
}
// ----------------------------------------------------------------------------- RouteTransformer
// --- RouteTransformer:菜单树 → vue-router 记录 ---
interface ConvertedRoute extends Omit<RouteRecordRaw, "children"> {
id?: number;
children?: ConvertedRoute[];
@@ -370,8 +371,8 @@ export class RouteTransformer {
}
}
// ----------------------------------------------------------------------------- RouteRegistry
/** 与静态壳层冲突的一级 path 段,后端动态挂载时跳过以免覆盖首页/仪表盘等 */
// --- RouteRegistry:校验通过后批量 addRoute ---
/** 与静态壳层冲突的一级 path 段,动态注册时跳过以免覆盖首页 / 仪表盘等 */
const RESERVED_SHELL_SEGMENTS = new Set(["home", "profile", "changelog", "dashboard"]);
function pathFirstSegment(path: string): string {
+6 -8
View File
@@ -6,29 +6,27 @@ import { setupAfterEachGuard } from "./afterEach";
import "@utils/ui";
/**
* `staticRoutes`
* `addRoute`
* `staticRoutes` `beforeEach` `RouteRegistry`
* `initRouter` / `app.use(router)`
*/
export const router = createRouter({
history: createWebHashHistory(),
routes: staticRoutes,
// 刷新时,滚动条位置还原
scrollBehavior: () => ({ left: 0, top: 0 }),
});
// 初始化路由
export function initRouter(app: App<Element>): void {
setupBeforeEachGuard(router); // 路由前置守卫
setupAfterEachGuard(router); // 路由后置守卫
setupBeforeEachGuard(router);
setupAfterEachGuard(router);
app.use(router);
}
// 主页路径;须与 `staticRoutes` 首页子路由一致(当前为 `/home`
/** 须与 `staticRoutes` 首页子路由 path 一致 */
export const HOME_PAGE_PATH = "/home";
export { HOME_ROUTE_NAME, ROOT_LAYOUT_ROUTE_NAME };
/** `@/router` 一处导入的运行时模块 */
/** 动态路由注册与菜单转换(一般从 `@/router` 按需导入) */
export { RouteRegistry, ComponentLoader, RouteTransformer, RouteValidator } from "./dynamicRoutes";
export type { ValidationResult } from "./dynamicRoutes";
export { IframeRouteManager } from "./staticRoutes";
+6 -6
View File
@@ -13,7 +13,7 @@ export const HOME_MENU_META: RouteMeta = {
export const DASHBOARD_PARENT_META: RouteMeta = {
title: "menus.dashboard.title",
icon: "ri:pie-chfa-line",
icon: "ri:pie-chart-line",
alwaysShow: true,
};
@@ -92,7 +92,7 @@ export const ROUTE_COMPONENT_NESTED_PARENT = "/nested/router-view-parent";
export const ROUTE_PATH_LOGIN_ALT = "/auth/login";
/**
* art `src/layouts/index.vue` + `src/layouts/fa-*`
* art `src/layouts/index.vue` + `src/layouts/art-*`
* Left/Top/Mix `@/layouts/index.vue`使
*/
export const Layout = () => import("@/components/layouts/index.vue");
@@ -172,7 +172,7 @@ export const dashboardLayoutChildren: AppRouteRecordRaw[] = [
component: () => import("@views/dashboard/ecommerce/index.vue"),
meta: {
title: "menus.dashboard.ecommerce",
icon: "ri:bar-chfa-box-line",
icon: "ri:bar-chart-box-line",
keepAlive: false,
},
},
@@ -229,7 +229,7 @@ export const dashboardLayoutChildren: AppRouteRecordRaw[] = [
component: () => import("@views/dashboard/tutorial/index.vue"),
meta: {
title: "menus.dashboard.tutorial",
icon: "ri:movie-2-line",
icon: "ri:book-2-line",
keepAlive: true,
},
},
@@ -386,7 +386,7 @@ export const staticRoutes: AppRouteRecordRaw[] = [
{
path: "/login",
name: "Login",
meta: { hidden: true, title: "menus.login.title" },
meta: { hidden: true, isHideTab: true, title: "menus.login.title" },
component: () => import("@views/module_system/auth/login/index.vue"),
},
/** 无 Layout 全屏异常页;守卫与白名单跳转使用(勿再在 RootLayout 下重复挂载同组件) */
@@ -458,7 +458,7 @@ export const staticRoutes: AppRouteRecordRaw[] = [
},
{
path: "/outside",
component: () => import("@/components/layouts/index.vue"),
component: () => import("@/layouts/index.vue"),
name: "Outside",
meta: { title: "menus.outside.title" },
children: [
+3 -6
View File
@@ -7,7 +7,7 @@ import { useUserStore } from "./modules/user.store";
import { useDictStore } from "./modules/dict.store";
import { useNoticeStore } from "./modules/notice.store";
import { useConfigStore } from "./modules/config.store";
import { useTagsViewStore } from "./modules/tags-view.store";
import { useWorktabStore } from "./modules/worktab.store";
const store = createPinia();
@@ -20,17 +20,15 @@ export function initStore(app: App<Element>) {
export * from "./modules/app.store";
export * from "./modules/config.store";
export * from "./modules/dict.store";
export * from "./modules/lock.store";
export * from "./modules/menu.store";
export * from "./modules/notice.store";
export * from "./modules/setting.store";
export * from "./modules/table.store";
export * from "./modules/tags-view.store";
export * from "./modules/user.store";
export * from "./modules/worktab.store";
export { store };
export { useUserStore, useDictStore, useNoticeStore, useConfigStore, useTagsViewStore };
export { useUserStore, useDictStore, useNoticeStore, useConfigStore, useWorktabStore };
export interface RefreshCacheOptions {
dictTypes?: string[];
@@ -57,7 +55,6 @@ export async function refreshAppCaches(opts: RefreshCacheOptions = {}) {
const dictStore = useDictStore(store);
const noticeStore = useNoticeStore(store);
const configStore = useConfigStore(store);
const tagsViewStore = useTagsViewStore(store);
const tasks: Promise<any>[] = [];
@@ -87,6 +84,6 @@ export async function refreshAppCaches(opts: RefreshCacheOptions = {}) {
}
if (clearTags) {
await tagsViewStore.delAllViews();
useWorktabStore(store).clearAll();
}
}
@@ -1,93 +0,0 @@
/**
*
*
*
*
* ##
*
* -
* -
* -
* -
*
* ## 使
*
* -
* -
* -
*
* ##
*
* - 使 localStorage
* -
*
* @module store/modules/lock.store
* @author FastapiAdmin Team
*/
import { defineStore } from "pinia";
import { ref } from "vue";
export const useLockStore = defineStore(
"lockStore",
() => {
// 是否锁定屏幕
const isLock = ref(false);
// 锁屏密码
const password = ref("");
/**
*
*/
const getLockInfo = () => ({
isLock: isLock.value,
password: password.value,
});
/**
*
* @param lockInfo
*/
const setLockInfo = (lockInfo: { isLock?: boolean; password?: string }) => {
if (lockInfo.isLock !== undefined) {
isLock.value = lockInfo.isLock;
}
if (lockInfo.password !== undefined) {
password.value = lockInfo.password;
}
};
/**
*
*/
const resetLockInfo = () => {
isLock.value = false;
password.value = "";
};
/**
*
* @param inputPassword
* @returns
*/
const unLock = (inputPassword: string) => {
if (password.value === inputPassword) {
resetLockInfo();
return true;
} else {
return false;
}
};
return {
isLock,
password,
getLockInfo,
setLockInfo,
resetLockInfo,
unLock,
};
},
{
persist: true,
}
);
@@ -1,6 +1,6 @@
/**
* useStorage / persist
* `reload` `refresh` `FaPageContent` RouterView useCommon.refresh
* `reload` `refresh` `ArtPageContent` RouterView useCommon.refresh
*/
import { defineStore } from "pinia";
import { ref, computed, watch } from "vue";
@@ -50,6 +50,7 @@ export const useSettingsStore = defineStore(
const autoClose = ref(SETTING_DEFAULT_CONFIG.autoClose);
const uniqueOpened = ref(SETTING_DEFAULT_CONFIG.uniqueOpened);
const colorWeak = ref(SETTING_DEFAULT_CONFIG.colorWeak);
/** 供布局监听:`reload()` 取反以触发主内容区整页刷新 */
const refresh = ref(SETTING_DEFAULT_CONFIG.refresh);
const holidayFireworksLoaded = ref(SETTING_DEFAULT_CONFIG.holidayFireworksLoaded);
@@ -288,7 +289,7 @@ export const useSettingsStore = defineStore(
menuOpen.value = open;
};
/** 切换 `refresh`,驱动 `layouts/fa-page-content` 内 `v-if="isRefresh"` 重建视图 */
/** 切换 `refresh`,驱动 `layouts/art-page-content` 内 `v-if="isRefresh"` 重建视图 */
const reload = () => {
refresh.value = !refresh.value;
};
+3 -32
View File
@@ -6,54 +6,25 @@ import { defineStore } from "pinia";
import { ref } from "vue";
import { TableSizeEnum } from "@/enums/formEnum";
// 表格
export const useTableStore = defineStore(
"tableStore",
() => {
// 表格大小
// --- 表格样式 ---
const tableSize = ref(TableSizeEnum.DEFAULT);
// 斑马纹
const isZebra = ref(false);
// 边框
const isBorder = ref(false);
// 表头背景
const isHeaderBackground = ref(false);
// 是否全屏
// --- 交互 ---
const isFullScreen = ref(false);
// 是否开启表格行拖拽排序(工具栏开关,与 ArtTable + VueDraggable 配合)
/** 工具栏「行拖拽」;仅当表格数据为可变数组时有效 */
const isRowDrag = ref(false);
/**
*
* @param size
*/
const setTableSize = (size: TableSizeEnum) => (tableSize.value = size);
/**
*
* @param value
*/
const setIsZebra = (value: boolean) => (isZebra.value = value);
/**
*
* @param value
*/
const setIsBorder = (value: boolean) => (isBorder.value = value);
/**
*
* @param value
*/
const setIsHeaderBackground = (value: boolean) => (isHeaderBackground.value = value);
/**
*
* @param value
*/
const setIsFullScreen = (value: boolean) => (isFullScreen.value = value);
const setIsRowDrag = (value: boolean) => (isRowDrag.value = value);
return {
@@ -1,313 +0,0 @@
/**
*
*
*
*
* ##
*
* - 访
* -
* -
* -
* - //
* -
*
* ## 使
*
* -
* -
* -
* -
*
* ##
*
* - affix
* - keepAlive缓存
* -
* - 访
*
* @module store/modules/tags-view.store
* @author FastapiAdmin Team
*/
import { defineStore } from "pinia";
import { ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { store } from "@stores";
export const useTagsViewStore = defineStore(
"tagsViewStore",
() => {
const visitedViews = ref<TagView[]>([]);
const cachedViews = ref<string[]>([]);
const router = useRouter();
const route = useRoute();
/**
* 访访
*/
function addVisitedView(view: TagView) {
// 如果已经存在于已访问的视图列表中或者是重定向地址,则不再添加
if (view.path.startsWith("/redirect")) {
return;
}
if (visitedViews.value.some((v) => v.name === view.name)) {
return;
}
// 如果视图是固定的(affix),则在已访问的视图列表的开头添加
if (view.affix) {
visitedViews.value.unshift(view);
} else {
// 如果视图不是固定的,则在已访问的视图列表的末尾添加
visitedViews.value.push(view);
}
}
/**
*
*/
function addCachedView(view: TagView) {
const viewName = view.name;
// 如果缓存视图名称已经存在于缓存视图列表中,则不再添加
if (cachedViews.value.includes(viewName)) {
return;
}
// 如果视图需要缓存(keepAlive),则将其路由名称添加到缓存视图列表中
if (view.keepAlive) {
cachedViews.value.push(viewName);
}
}
/**
* 访
*/
function delVisitedView(view: TagView) {
return new Promise((resolve) => {
for (const [i, v] of visitedViews.value.entries()) {
// 找到与指定视图路径匹配的视图,在已访问视图列表中删除该视图
if (v.path === view.path) {
visitedViews.value.splice(i, 1);
break;
}
}
resolve([...visitedViews.value]);
});
}
function delCachedView(view: TagView) {
const viewName = view.name;
return new Promise((resolve) => {
const index = cachedViews.value.indexOf(viewName);
if (index > -1) {
cachedViews.value.splice(index, 1);
}
resolve([...cachedViews.value]);
});
}
function delOtherVisitedViews(view: TagView) {
return new Promise((resolve) => {
visitedViews.value = visitedViews.value.filter((v) => {
return v?.affix || v.path === view.path;
});
resolve([...visitedViews.value]);
});
}
function delOtherCachedViews(view: TagView) {
const viewName = view.name as string;
return new Promise((resolve) => {
const index = cachedViews.value.indexOf(viewName);
if (index > -1) {
cachedViews.value = cachedViews.value.slice(index, index + 1);
} else {
// if index = -1, there is no cached tags
cachedViews.value = [];
}
resolve([...cachedViews.value]);
});
}
function updateVisitedView(view: TagView) {
for (let v of visitedViews.value) {
if (v.path === view.path) {
v = Object.assign(v, view);
break;
}
}
}
function addView(view: TagView) {
addVisitedView(view);
addCachedView(view);
}
function delView(view: TagView) {
return new Promise((resolve) => {
delVisitedView(view);
delCachedView(view);
resolve({
visitedViews: [...visitedViews.value],
cachedViews: [...cachedViews.value],
});
});
}
function delOtherViews(view: TagView) {
return new Promise((resolve) => {
delOtherVisitedViews(view);
delOtherCachedViews(view);
resolve({
visitedViews: [...visitedViews.value],
cachedViews: [...cachedViews.value],
});
});
}
function delLeftViews(view: TagView) {
return new Promise((resolve) => {
const currIndex = visitedViews.value.findIndex((v) => v.path === view.path);
if (currIndex === -1) {
return;
}
visitedViews.value = visitedViews.value.filter((item, index) => {
if (index >= currIndex || item?.affix) {
return true;
}
const cacheIndex = cachedViews.value.indexOf(item.name);
if (cacheIndex > -1) {
cachedViews.value.splice(cacheIndex, 1);
}
return false;
});
resolve({
visitedViews: [...visitedViews.value],
});
});
}
function delRightViews(view: TagView) {
return new Promise((resolve) => {
const currIndex = visitedViews.value.findIndex((v) => v.path === view.path);
if (currIndex === -1) {
return;
}
visitedViews.value = visitedViews.value.filter((item, index) => {
if (index <= currIndex || item?.affix) {
return true;
}
});
resolve({
visitedViews: [...visitedViews.value],
});
});
}
function delAllViews() {
return new Promise((resolve) => {
const affixTags = visitedViews.value.filter((tag) => tag?.affix);
visitedViews.value = affixTags;
cachedViews.value = [];
resolve({
visitedViews: [...visitedViews.value],
cachedViews: [...cachedViews.value],
});
});
}
function delAllVisitedViews() {
return new Promise((resolve) => {
const affixTags = visitedViews.value.filter((tag) => tag?.affix);
visitedViews.value = affixTags;
resolve([...visitedViews.value]);
});
}
function delAllCachedViews() {
return new Promise((resolve) => {
cachedViews.value = [];
resolve([...cachedViews.value]);
});
}
/**
* tagView
*/
function closeCurrentView() {
const tags: TagView = {
name: route.name as string,
title: route.meta.title as string,
path: route.path,
fullPath: route.fullPath,
affix: route.meta?.affix,
keepAlive: route.meta?.keepAlive,
query: route.query,
};
delView(tags).then((res: any) => {
if (isActive(tags)) {
toLastView(res.visitedViews, tags);
}
});
}
function isActive(tag: TagView) {
return tag.path === route.path;
}
function toLastView(visitedViews: TagView[], view?: TagView) {
const latestView = visitedViews.slice(-1)[0];
if (latestView && latestView.fullPath) {
router.push(latestView.fullPath);
} else {
// now the default is to redirect to the home page if there is no tags-view,
// you can adjust it according to your needs.
const vn = view?.name != null ? String(view.name) : "";
if (
vn === "Home" ||
vn === "Workplace" ||
vn === "Dashboard" ||
vn.startsWith("Dashboard")
) {
// to reload home page
if (view?.fullPath) {
router.replace("/redirect" + view.fullPath);
} else {
router.push("/");
}
} else {
router.push("/");
}
}
}
return {
visitedViews,
cachedViews,
addVisitedView,
addCachedView,
delVisitedView,
delCachedView,
delOtherVisitedViews,
delOtherCachedViews,
updateVisitedView,
addView,
delView,
delOtherViews,
delLeftViews,
delRightViews,
delAllViews,
delAllVisitedViews,
delAllCachedViews,
closeCurrentView,
isActive,
toLastView,
};
},
{
persist: true,
}
);
export function useTagsViewStoreHook() {
return useTagsViewStore(store);
}
+7 -8
View File
@@ -16,7 +16,7 @@ import type { MenuTable } from "@/api/module_system/menu";
import { Auth } from "@utils/auth";
import { ResultEnum } from "@/enums/api/result.enum";
import { ElNotification } from "element-plus";
import { store, useTagsViewStore, useDictStore } from "@stores";
import { store, useDictStore } from "@stores";
import type { UserInfo } from "@/api/module_system/user";
/** {@link useUserStore} 的 `logout` 可选参数 */
@@ -154,11 +154,9 @@ export const useUserStore = defineStore(
return;
}
// 不同用户登录清空工作台标签
if (String(currentUserId) !== lastUserId) {
const worktabStore = useWorktabStore();
worktabStore.opened = [];
worktabStore.keepAliveExclude = [];
// 不同用户登录清空工作台标签(含 current,避免与路由脱节;并与持久化一致)
if (String(currentUserId) !== String(lastUserId)) {
useWorktabStore().clearAll();
}
// 清除临时存储
@@ -311,6 +309,8 @@ export const useUserStore = defineStore(
accessToken.value = "";
refreshToken.value = "";
prems.value = [];
/** 登出 / 认证失效:会话结束,工作栏与 KeepAlive exclude 一并清空(pinia 持久化随之写入) */
useWorktabStore().clearAll();
}
/**
@@ -355,8 +355,7 @@ export const useUserStore = defineStore(
Auth.clearAuth();
// 重置用户信息
clearUserInfo();
// 清除标签视图
useTagsViewStore().delAllViews();
useWorktabStore().clearAll();
// 重置字典
useDictStore(store).clearDictData();
+87 -21
View File
@@ -23,7 +23,7 @@
*
* ##
*
* -
* - path path
* -
* - KeepAlive
* -
@@ -43,6 +43,7 @@ import { router } from "@/router";
import { LocationQueryRaw, Router } from "vue-router";
import { WorkTab } from "@/types";
import { useCommon } from "@/hooks/core/useCommon";
import { ROUTE_PATH_LOGIN_ALT } from "@/router/staticRoutes";
interface WorktabState {
current: Partial<WorkTab>;
@@ -108,6 +109,19 @@ export const useWorktabStore = defineStore(
}
};
/**
* URL opened `current`
*/
const ensureRouterMatchesOpenedTab = (): void => {
if (!opened.value.length) return;
const locPath = router.currentRoute.value.path;
if (opened.value.some((t) => t.path === locPath)) return;
const tab = current.value as Partial<WorkTab>;
if (tab.path) {
safeRouterPush(tab as WorkTab);
}
};
/**
*
*/
@@ -122,14 +136,8 @@ export const useWorktabStore = defineStore(
removeKeepAliveExclude(tab.name);
}
// 先根据路由名称查找(应对动态路由参数导致的多开问题),找不到再根据路径查找
let existingIndex = -1;
if (tab.name) {
existingIndex = opened.value.findIndex((t) => t.name === tab.name);
}
if (existingIndex === -1) {
existingIndex = findTabIndex(tab.path);
}
// 仅以 path 识别标签:同一 route_name(同一组件)可对不同 path 同时保留多条标签
const existingIndex = findTabIndex(tab.path);
if (existingIndex === -1) {
// 新增标签页
@@ -255,6 +263,7 @@ export const useWorktabStore = defineStore(
if (targetTab) {
current.value = targetTab;
}
ensureRouterMatchesOpenedTab();
};
/**
@@ -290,6 +299,7 @@ export const useWorktabStore = defineStore(
if (targetTab) {
current.value = targetTab;
}
ensureRouterMatchesOpenedTab();
};
/**
@@ -320,6 +330,7 @@ export const useWorktabStore = defineStore(
// 确保当前标签是激活状态
current.value = targetTab;
ensureRouterMatchesOpenedTab();
};
/**
@@ -365,14 +376,28 @@ export const useWorktabStore = defineStore(
};
/**
* keepAlive
* KeepAlive exclude
* remaining exclude route_name path
*/
const pushExcludeIfLastSiblingOfName = (
name: string | undefined,
remaining: WorkTab[],
tab: Pick<WorkTab, "keepAlive">
): void => {
if (!name || tab.keepAlive === false) return;
if (remaining.some((t) => t.name === name)) return;
if (!keepAliveExclude.value.includes(name)) {
keepAliveExclude.value.push(name);
}
};
/**
* keepAlive
* keepAlive !== false `=== false` undefined/true
* meta keepAlive
*/
const addKeepAliveExclude = (tab: WorkTab): void => {
if (!tab.keepAlive || !tab.name) return;
if (!keepAliveExclude.value.includes(tab.name)) {
keepAliveExclude.value.push(tab.name);
}
pushExcludeIfLastSiblingOfName(tab.name, opened.value, tab);
};
/**
@@ -388,11 +413,13 @@ export const useWorktabStore = defineStore(
*
*/
const markTabsToRemove = (tabs: WorkTab[]): void => {
tabs.forEach((tab) => {
const removedPaths = new Set(tabs.map((t) => t.path));
const futureOpened = opened.value.filter((t) => !removedPaths.has(t.path));
for (const tab of tabs) {
if (tab.name) {
addKeepAliveExclude(tab);
pushExcludeIfLastSiblingOfName(tab.name, futureOpened, tab);
}
});
}
};
/**
@@ -454,16 +481,30 @@ export const useWorktabStore = defineStore(
}
};
/** 登录页不应出现在工作台标签(历史持久化可能残留) */
const isLoginWorktab = (tab: Partial<WorkTab>): boolean =>
tab.name === "Login" || tab.path === "/login" || tab.path === ROUTE_PATH_LOGIN_ALT;
// 过滤出有效的标签页
const validTabs = opened.value.filter((tab) => isTabRouteValid(tab));
const validTabs = opened.value.filter(
(tab) => isTabRouteValid(tab) && !isLoginWorktab(tab)
);
if (validTabs.length !== opened.value.length) {
console.warn("发现无效的标签页路由,已自动清理");
const validPaths = new Set(validTabs.map((t) => t.path));
// 未走 removeTab 批量剔除的标签:须同步 exclude;同名多标签时仅最后一条删尽才 exclude
for (const tab of opened.value) {
if (!validPaths.has(tab.path) && tab.name && tab.keepAlive !== false) {
pushExcludeIfLastSiblingOfName(tab.name, validTabs, tab);
}
}
opened.value = validTabs;
}
// 验证当前激活标签的有效性
const isCurrentValid = current.value && isTabRouteValid(current.value);
// 验证当前激活标签的有效性(登录页不应作为当前工作台标签)
const isCurrentValid =
current.value && isTabRouteValid(current.value) && !isLoginWorktab(current.value);
if (!isCurrentValid && validTabs.length > 0) {
console.warn("当前激活标签无效,已自动切换");
@@ -524,6 +565,30 @@ export const useWorktabStore = defineStore(
}
};
/**
* visited `opened` KeepAlive include
* `current`/ `router`
*/
const syncCurrentFromRoute = (tab: {
path: string;
name: string;
title: string;
icon?: string;
keepAlive: boolean;
params?: object;
query?: LocationQueryRaw;
}): void => {
current.value = {
path: tab.path,
name: tab.name,
title: tab.title,
icon: tab.icon,
keepAlive: tab.keepAlive,
params: tab.params,
query: tab.query,
};
};
return {
// 状态
current,
@@ -557,6 +622,7 @@ export const useWorktabStore = defineStore(
getTabTitle,
updateTabTitle,
resetTabTitle,
syncCurrentFromRoute,
};
},
{
+67 -66
View File
@@ -17,7 +17,7 @@
}
// 处理移动端组件兼容性
@media screen and (max-width: 640px) {
@media screen and (width <= 640px) {
* {
cursor: default !important;
}
@@ -40,8 +40,8 @@
// 色弱模式
.color-weak {
filter: invert(80%);
-webkit-filter: invert(80%);
filter: invert(80%);
}
#noop {
@@ -52,14 +52,14 @@
.langDropDownStyle {
// 选中项背景颜色
.is-selected {
background-color: var(--art-el-active-color) !important;
background-color: var(--fa-el-active-color) !important;
}
// 语言切换按钮菜单样式优化
.lang-btn-item {
.el-dropdown-menu__item {
padding-left: 13px !important;
padding-right: 6px !important;
padding-left: 13px !important;
margin-bottom: 3px !important;
}
@@ -70,23 +70,23 @@
}
.menu-txt {
min-width: 60px;
display: block;
min-width: 60px;
}
i {
font-size: 10px;
margin-left: 10px;
font-size: 10px;
}
}
}
// 盒子默认边框
.page-content {
border: 1px solid var(--art-card-border) !important;
border: 1px solid var(--fa-card-border) !important;
}
@mixin art-card-base($border-color, $shadow: none, $radius-diff: 4px) {
@mixin fa-card-base($border-color, $shadow: none, $radius-diff: 4px) {
background: var(--default-box-color);
border: 1px solid #{$border-color} !important;
border-radius: calc(var(--custom-radius) + #{$radius-diff}) !important;
@@ -95,63 +95,63 @@
--el-card-border-color: var(--default-border) !important;
}
.art-card,
.art-card-sm,
.art-card-xs {
border: 1px solid var(--art-card-border);
.fa-card,
.fa-card-sm,
.fa-card-xs {
border: 1px solid var(--fa-card-border);
}
// 盒子边框
[data-box-mode='border-mode'] {
[data-box-mode="border-mode"] {
.page-content,
.art-table-card {
border: 1px solid var(--art-card-border) !important;
.fa-table-card {
border: 1px solid var(--fa-card-border) !important;
}
.art-card {
@include art-card-base(var(--art-card-border), none, 4px);
.fa-card {
@include fa-card-base(var(--fa-card-border), none, 4px);
}
.art-card-sm {
@include art-card-base(var(--art-card-border), none, 0px);
.fa-card-sm {
@include fa-card-base(var(--fa-card-border), none, 0);
}
.art-card-xs {
@include art-card-base(var(--art-card-border), none, -4px);
.fa-card-xs {
@include fa-card-base(var(--fa-card-border), none, -4px);
}
}
// 盒子阴影
[data-box-mode='shadow-mode'] {
[data-box-mode="shadow-mode"] {
.page-content,
.art-table-card {
box-shadow: 0px 0px 4px 0px rgba(0, 0, 0, 0.04) !important;
border: 1px solid var(--art-gray-200) !important;
.fa-table-card {
border: 1px solid var(--fa-gray-200) !important;
box-shadow: 0 0 4px 0 rgb(0 0 0 / 4%) !important;
}
.layout-sidebar {
border-right: 1px solid var(--art-card-border) !important;
border-right: 1px solid var(--fa-card-border) !important;
}
.art-card {
@include art-card-base(
var(--art-gray-200),
.fa-card {
@include fa-card-base(
var(--fa-gray-200),
(0 1px 3px 0 rgba(0, 0, 0, 0.03), 0 1px 2px -1px rgba(0, 0, 0, 0.08)),
4px
);
}
.art-card-sm {
@include art-card-base(
var(--art-gray-200),
.fa-card-sm {
@include fa-card-base(
var(--fa-gray-200),
(0 1px 3px 0 rgba(0, 0, 0, 0.03), 0 1px 2px -1px rgba(0, 0, 0, 0.08)),
2px
);
}
.art-card-xs {
@include art-card-base(
var(--art-gray-200),
.fa-card-xs {
@include fa-card-base(
var(--fa-gray-200),
(0 1px 2px 0 rgba(0, 0, 0, 0.03), 0 1px 1px -1px rgba(0, 0, 0, 0.08)),
-4px
);
@@ -162,58 +162,62 @@
.el-full-screen {
position: fixed;
top: 0;
left: 0;
right: 0;
width: 100vw !important;
height: 100% !important;
left: 0;
z-index: 2300;
margin-top: 0;
padding: 15px;
box-sizing: border-box;
background-color: var(--default-box-color);
display: flex;
flex-direction: column;
width: 100vw !important;
height: 100% !important;
padding: 15px;
margin-top: 0;
background-color: var(--default-box-color);
}
// 表格卡片 FaTableHeader + FaTable 组成纵向 flex表格区 flex 填满分页在表格正下方
.art-table-card {
flex: 1;
.fa-table-card {
display: flex;
flex: 1;
flex-direction: column;
min-width: 0;
min-height: 0;
margin-top: 12px;
border-radius: calc(var(--custom-radius) / 2 + 2px) !important;
.el-card__body {
height: 100%;
display: flex;
flex: 1 1 auto;
flex-direction: column;
min-height: 0;
overflow: hidden;
/** 工具栏区不参与压缩,表格区 FaTable 使用 flex:1 占满剩余高度 */
#art-table-header {
#fa-table-header {
flex-shrink: 0;
}
}
}
// 容器全高沿布局 flex 链填满 `#app-scroll-main` 内剩余高度底部自然对齐 #app-content padding勿再用 100vh 估算
.art-full-height {
.fa-full-height {
box-sizing: border-box;
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
min-width: 0;
width: 100%;
min-width: 0;
min-height: 0;
@media (max-width: 640px) {
@media (width <= 640px) {
flex: 0 1 auto;
min-height: 0;
height: auto;
min-height: 0;
}
}
// 徽章样式
.art-badge {
.fa-badge {
position: absolute;
top: 0;
right: 20px;
@@ -225,33 +229,33 @@
border-radius: 50%;
animation: breathe 1.5s ease-in-out infinite;
&.art-badge-horizontal {
&.fa-badge-horizontal {
right: 0;
}
&.art-badge-mixed {
&.fa-badge-mixed {
right: 0;
}
&.art-badge-dual {
right: 5px;
&.fa-badge-dual {
top: 5px;
right: 5px;
bottom: auto;
}
}
// 文字徽章样式
.art-text-badge {
.fa-text-badge {
position: absolute;
top: 0;
right: 12px;
bottom: 0;
min-width: 20px;
height: 18px;
line-height: 17px;
padding: 0 5px;
margin: auto;
font-size: 10px;
line-height: 17px;
color: #fff;
text-align: center;
background: #fd4e4e;
@@ -276,20 +280,17 @@
}
// 修复老机型 loading 定位问题
.art-loading-fix {
.fa-loading-fix {
position: fixed !important;
top: 0 !important;
left: 0 !important;
right: 0 !important;
bottom: 0 !important;
width: 100vw !important;
height: 100vh !important;
inset: 0 !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
width: 100vw !important;
height: 100vh !important;
}
.art-loading-fix .el-loading-spinner {
.fa-loading-fix .el-loading-spinner {
position: static !important;
top: auto !important;
left: auto !important;
@@ -297,7 +298,7 @@
}
// 去除移动端点击背景色
@media screen and (max-width: 1180px) {
@media screen and (width <= 1180px) {
* {
-webkit-tap-highlight-color: transparent;
}
+14 -16
View File
@@ -3,7 +3,7 @@
* 单页面移除深色主题 document.getElementsByTagName("html")[0].removeAttribute('class')
*/
$font-color: rgba(#ffffff, 0.85);
$font-color: rgba(#fff, 0.85);
/* 覆盖element-plus默认深色背景色 */
html.dark {
@@ -17,7 +17,7 @@ html.dark {
// 输入区域背景颜色
--w-e-textarea-bg-color: #090909;
// 工具栏文字颜色
--w-e-toolbar-color: var(--art-gray-600);
--w-e-toolbar-color: var(--fa-gray-600);
// 选中菜单颜色
--w-e-toolbar-active-bg-color: #25262b;
// 弹窗边框颜色
@@ -31,7 +31,7 @@ html.dark {
// 按钮背景颜色
--w-e-modal-button-bg-color: #090909;
// hover toolbar 背景颜色
--w-e-toolbar-active-color: var(--art-gray-800);
--w-e-toolbar-active-color: var(--fa-gray-800);
}
.dark {
@@ -44,10 +44,18 @@ html.dark {
*:not(pre code *) {
color: inherit !important;
}
.w-e-text-container [data-slate-editor] .table-container th:last-of-type {
border-right: 1px solid var(--default-border-dashed) !important;
}
.w-e-modal {
background-color: var(--fa-color);
}
}
// 分隔线
.w-e-bar-divider {
background-color: var(--art-gray-300) !important;
background-color: var(--fa-gray-300) !important;
}
.w-e-select-list,
@@ -71,23 +79,13 @@ html.dark {
/* 代码块 */
.w-e-text-container [data-slate-editor] pre > code {
background-color: #25262b !important;
text-shadow: none !important;
background-color: #25262b !important;
}
/* 引用 */
.w-e-text-container [data-slate-editor] blockquote {
background-color: var(--fa-color);
border-left: 4px solid var(--default-border-dashed) !important;
background-color: var(--art-color);
}
.editor-wrapper {
.w-e-text-container [data-slate-editor] .table-container th:last-of-type {
border-right: 1px solid var(--default-border-dashed) !important;
}
.w-e-modal {
background-color: var(--art-color);
}
}
}
+1 -1
View File
@@ -1,2 +1,2 @@
// 导入暗黑主题
@use 'element-plus/theme-chalk/src/dark/css-vars.scss' as *;
@use "element-plus/theme-chalk/src/dark/css-vars.scss" as *;
+19 -19
View File
@@ -1,34 +1,34 @@
// https://github.com/element-plus/element-plus/blob/dev/packages/theme-chalk/src/common/var.scss
// 自定义Element 亮色主题
@forward 'element-plus/theme-chalk/src/common/var.scss' with (
@forward "element-plus/theme-chalk/src/common/var.scss" with (
$colors: (
'white': #ffffff,
'black': #000000,
'success': (
'base': #13deb9
"white": #ffffff,
"black": #000000,
"success": (
"base": #13deb9,
),
'warning': (
'base': #ffae1f
"warning": (
"base": #ffae1f,
),
'danger': (
'base': #ff4d4f
"danger": (
"base": #ff4d4f,
),
"error": (
"base": #fa896b,
),
'error': (
'base': #fa896b
)
),
$button: (
'hover-bg-color': var(--el-color-primary-light-9),
'hover-border-color': var(--el-color-primary),
'border-color': var(--el-color-primary),
'text-color': var(--el-color-primary)
"hover-bg-color": var(--el-color-primary-light-9),
"hover-border-color": var(--el-color-primary),
"border-color": var(--el-color-primary),
"text-color": var(--el-color-primary),
),
$messagebox: (
'border-radius': '12px'
"border-radius": "12px",
),
$popover: (
'padding': '14px',
'border-radius': '10px'
"padding": "14px",
"border-radius": "10px",
)
);
+39 -46
View File
@@ -9,14 +9,11 @@
// --el-border-color: #E4E4E7 !important; // DCDFE6
// 按钮粗度
--el-font-weight-primary: 400 !important;
--el-component-custom-height: 36px !important;
--el-component-size: var(--el-component-custom-height) !important;
// 边框按钮圆角...
--el-border-radius-base: calc(var(--custom-radius) / 3 + 2px) !important;
--el-border-radius-small: calc(var(--custom-radius) / 3 + 4px) !important;
--el-messagebox-border-radius: calc(var(--custom-radius) / 3 + 4px) !important;
--el-popover-border-radius: calc(var(--custom-radius) / 3 + 4px) !important;
@@ -34,7 +31,7 @@
// 日期选择器
.el-date-range-picker {
--el-datepicker-inrange-bg-color: var(--art-gray-200) !important;
--el-datepicker-inrange-bg-color: var(--fa-gray-200) !important;
}
// el-card 背景色跟系统背景色保持一致
@@ -44,15 +41,11 @@ html.dark .el-card {
// 修改 el-pagination 大小
.el-pagination--default {
& {
--el-pagination-button-width: 32px !important;
--el-pagination-button-height: var(--el-pagination-button-width) !important;
}
--el-pagination-button-width: 32px !important;
--el-pagination-button-height: var(--el-pagination-button-width) !important;
@media (max-width: 1180px) {
& {
--el-pagination-button-width: 28px !important;
}
@media (width <= 1180px) {
--el-pagination-button-width: 28px !important;
}
.el-select--default .el-select__wrapper {
@@ -118,9 +111,9 @@ html.dark .el-card {
}
.el-dialog {
overflow: hidden;
border-radius: 100px !important;
border-radius: calc(var(--custom-radius) / 1.2 + 2px) !important;
overflow: hidden;
}
.el-dialog__header {
@@ -130,8 +123,8 @@ html.dark .el-card {
}
.el-dialog__body {
padding: 25px 0 !important;
position: relative; // 为了兼容 el-pagination 样式需要设置 relative不然会影响 el-pagination 的样式比如 el-pagination__jump--small 会被影响导致 el-pagination__jump--small 按钮无法点击详见 URL_ADDRESS.com/element-plus/element-plus/issues/5684#issuecomment-1176299275;
padding: 25px 0 !important;
}
.el-dialog.el-dialog-border {
@@ -140,12 +133,12 @@ html.dark .el-card {
&::before,
// 下边框
&::after {
content: '';
position: absolute;
left: -16px;
width: calc(100% + 32px);
height: 1px;
background-color: var(--art-gray-300);
content: "";
background-color: var(--fa-gray-300);
}
&::before {
@@ -163,9 +156,9 @@ html.dark .el-card {
background-color: var(--default-box-color) !important;
border: 0 !important;
box-shadow:
0 6px 16px 0 rgba(0, 0, 0, 0.08),
0 3px 6px -4px rgba(0, 0, 0, 0.12),
0 9px 28px 8px rgba(0, 0, 0, 0.05) !important;
0 6px 16px 0 rgb(0 0 0 / 8%),
0 3px 6px -4px rgb(0 0 0 / 12%),
0 9px 28px 8px rgb(0 0 0 / 5%) !important;
p {
font-size: 13px;
@@ -175,21 +168,21 @@ html.dark .el-card {
// 修改 el-dropdown 样式
.el-dropdown-menu {
padding: 6px !important;
border-radius: 10px !important;
border: none !important;
border-radius: 10px !important;
.el-dropdown-menu__item {
padding: 6px 16px !important;
border-radius: 6px !important;
&:hover:not(.is-disabled) {
color: var(--art-gray-900) !important;
background-color: var(--art-el-active-color) !important;
color: var(--fa-gray-900) !important;
background-color: var(--fa-el-active-color) !important;
}
&:focus:not(.is-disabled) {
color: var(--art-gray-900) !important;
background-color: var(--art-gray-200) !important;
color: var(--fa-gray-900) !important;
background-color: var(--fa-gray-200) !important;
}
}
}
@@ -209,23 +202,23 @@ html.dark .el-card {
}
// 处理移动端组件兼容性
@media screen and (max-width: 640px) {
@media screen and (width <= 640px) {
.el-message-box,
.el-dialog {
width: calc(100% - 24px) !important;
}
.el-date-picker.has-sidebar.has-time {
width: calc(100% - 24px);
left: 12px !important;
width: calc(100% - 24px);
}
.el-picker-panel *[slot='sidebar'],
.el-picker-panel *[slot="sidebar"],
.el-picker-panel__sidebar {
display: none;
}
.el-picker-panel *[slot='sidebar'] + .el-picker-panel__body,
.el-picker-panel *[slot="sidebar"] + .el-picker-panel__body,
.el-picker-panel__sidebar + .el-picker-panel__body {
margin-left: 0;
}
@@ -234,8 +227,8 @@ html.dark .el-card {
// 修改el-button样式
.el-button {
&.el-button--text {
background-color: transparent !important;
padding: 0 !important;
background-color: transparent !important;
span {
margin-left: 0 !important;
@@ -292,9 +285,9 @@ html.dark .el-card {
border-radius: 4px !important;
&::before {
content: '';
height: 4px !important;
top: 5px !important;
height: 4px !important;
content: "";
background-color: #fff !important;
transform: scale(0.6) !important;
}
@@ -307,8 +300,8 @@ html.dark .el-card {
height: 8px;
margin: auto;
border: 2px solid var(--el-checkbox-checked-icon-color);
border-left: 0;
border-top: 0;
border-left: 0;
transform: translate(-45%, -60%) rotate(45deg) scale(0.86) !important;
transform-origin: center;
}
@@ -331,8 +324,8 @@ html.dark .el-card {
transition: all 0.3s;
&:hover {
background-color: var(--art-hover-color) !important;
color: var(--art-gray-900) !important;
color: var(--fa-gray-900) !important;
background-color: var(--fa-hover-color) !important;
}
}
@@ -345,8 +338,8 @@ html.dark .el-card {
}
.el-table__column-filter-trigger i {
color: var(--theme-color) !important;
margin: -3px 0 0 2px;
color: var(--theme-color) !important;
}
// 去除 el-dropdown 鼠标放上去出现的边框
@@ -355,7 +348,7 @@ html.dark .el-card {
}
// ipad 表单右侧按钮优化
@media screen and (max-width: 1180px) {
@media screen and (width <= 1180px) {
.el-table-fixed-column--right {
padding-right: 0 !important;
}
@@ -432,14 +425,14 @@ html.dark .el-card {
border-radius: 6px !important;
&.is-selected {
color: var(--art-gray-900) !important;
font-weight: 400 !important;
background-color: var(--art-el-active-color) !important;
margin-bottom: 4px !important;
font-weight: 400 !important;
color: var(--fa-gray-900) !important;
background-color: var(--fa-el-active-color) !important;
}
&:hover {
background-color: var(--art-hover-color) !important;
background-color: var(--fa-hover-color) !important;
}
}
@@ -461,7 +454,7 @@ html.dark .el-card {
border-radius: 6px !important;
&:hover {
background-color: var(--art-gray-200) !important;
background-color: var(--fa-gray-200) !important;
}
}
}
@@ -488,26 +481,26 @@ html.dark .el-card {
// 优化树型菜单样式
.el-tree-node__content {
border-radius: 4px;
margin-bottom: 4px;
padding: 1px 0;
margin-bottom: 4px;
border-radius: 4px;
&:hover {
background-color: var(--art-hover-color) !important;
background-color: var(--fa-hover-color) !important;
}
}
.dark {
.el-tree--highlight-current .el-tree-node.is-current > .el-tree-node__content {
background-color: var(--art-gray-300) !important;
background-color: var(--fa-gray-300) !important;
}
}
// 隐藏折叠菜单弹窗 hover 出现的边框
.menu-left-popper:focus-within,
.horizontal-menu-popper:focus-within {
box-shadow: none !important;
outline: none !important;
box-shadow: none !important;
}
// 数字输入组件右侧按钮高度跟随自定义组件高度
+102 -101
View File
@@ -1,4 +1,5 @@
/* 文章标题设置(h1-h6*/
/* 文章标题设置(h1-h6 */
/* ------------------------------------------------ */
$font-color: #24292e;
@@ -8,16 +9,16 @@ $font-color: #24292e;
.markdown-body h4,
.markdown-body h5,
.markdown-body h6 {
color: var(--art-gray-800) !important;
margin: 30px 0 10px 0;
margin: 30px 0 10px;
font-weight: 600;
color: var(--fa-gray-800) !important;
}
.markdown-body h1 {
font-size: 30px;
}
@media only screen and (max-width: 550px) {
@media only screen and (width <= 550px) {
.markdown-body h1 {
font-size: 26px;
}
@@ -32,96 +33,98 @@ $font-color: #24292e;
}
/* 块引用 */
/* ------------------------------------------------ */
.markdown-body blockquote {
color: rgba(60, 60, 67, 0.7);
font-size: 15px !important;
border-left: 0.18em solid #e7e7e8;
background: #f8f8f8;
padding: 15px 1em;
font-size: 15px !important;
font-weight: 400 !important;
color: rgb(60 60 67 / 70%);
background: #f8f8f8;
border-left: 0.18em solid #e7e7e8;
}
/* 详情页文章字体颜色 */
/* ------------------------------------------------ */
.markdown-body p {
line-height: 28px;
margin-bottom: 10px;
line-height: 28px;
}
.markdown-body li,
.markdown-body p {
color: var(--art-gray-800) !important;
font-size: 16px !important;
color: var(--fa-gray-800) !important;
}
.dark .markdown-body li span {
color: var(--art-gray-800) !important;
color: var(--fa-gray-800) !important;
background-color: transparent !important;
}
.dark .markdown-body p span {
color: var(--art-gray-800) !important;
color: var(--fa-gray-800) !important;
background-color: transparent !important;
}
.line-numbers-mode {
background-color: var(--art-code-bg);
border-radius: 8px;
position: relative;
padding-left: 32px;
box-sizing: border-box;
padding-left: 32px;
background-color: var(--fa-code-bg);
border-radius: 8px;
}
.line-numbers-mode pre {
flex: 1;
background-color: var(--fa-code-bg);
border-radius: 0 8px 8px 0;
background-color: var(--art-code-bg);
}
.line-numbers-mode .line-numbers-wrapper {
position: absolute;
top: 0;
left: 0;
box-sizing: border-box;
width: 32px;
height: 100%;
text-align: center;
padding: 16px 0;
box-sizing: border-box;
border-right: 1px solid #000000;
position: absolute;
left: 0;
top: 0;
text-align: center;
border-right: 1px solid #000;
}
.line-numbers-mode .line-numbers-wrapper span {
height: 23.6px;
line-height: 23.6px;
display: block;
color: #72747b;
font-size: 13px;
box-sizing: border-box;
display: block;
height: 23.6px;
font-size: 13px;
line-height: 23.6px;
color: #72747b;
}
.line-numbers-mode .copy-btn {
position: absolute;
top: 10px;
right: 10px;
display: inline-block;
display: flex;
position: absolute;
right: 10px;
top: 10px;
color: rgb(255 255 255 / 60%);
text-align: center;
cursor: pointer;
opacity: 0;
background-color: #000;
border-radius: 5px;
text-align: center;
color: rgba(255, 255, 255, 0.6);
opacity: 0;
transition: opacity 0.3s;
}
.line-numbers-mode .copy-btn div {
width: 34px;
height: 34px;
line-height: 34px;
cursor: pointer;
text-align: center;
font-size: 20px;
line-height: 34px;
text-align: center;
cursor: pointer;
}
.line-numbers-mode:hover .copy-btn {
@@ -129,16 +132,16 @@ $font-color: #24292e;
}
.line-numbers-mode .copy-btn span {
height: 34px;
line-height: 34px;
font-size: 13px;
padding-left: 10px;
display: none;
height: 34px;
padding-left: 10px;
font-size: 13px;
line-height: 34px;
}
.line-numbers-mode .copy-btn .show-copy {
opacity: 1;
display: block;
opacity: 1;
}
.line-numbers-mode ::-webkit-scrollbar-track {
@@ -147,9 +150,9 @@ $font-color: #24292e;
.markdown-body .anchor {
float: left;
line-height: 1;
margin-left: -20px;
padding-right: 4px;
margin-left: -20px;
line-height: 1;
}
.markdown-body .anchor:focus {
@@ -162,9 +165,9 @@ $font-color: #24292e;
.markdown-body h4 .octicon-link,
.markdown-body h5 .octicon-link,
.markdown-body h6 .octicon-link {
color: #1b1f23;
vertical-align: middle;
visibility: hidden;
vertical-align: middle;
color: #1b1f23;
}
.markdown-body h1:hover .anchor,
@@ -185,26 +188,25 @@ $font-color: #24292e;
visibility: visible;
}
.markdown-body h1:hover .anchor .octicon-link:before,
.markdown-body h2:hover .anchor .octicon-link:before,
.markdown-body h3:hover .anchor .octicon-link:before,
.markdown-body h4:hover .anchor .octicon-link:before,
.markdown-body h5:hover .anchor .octicon-link:before,
.markdown-body h6:hover .anchor .octicon-link:before {
.markdown-body h1:hover .anchor .octicon-link::before,
.markdown-body h2:hover .anchor .octicon-link::before,
.markdown-body h3:hover .anchor .octicon-link::before,
.markdown-body h4:hover .anchor .octicon-link::before,
.markdown-body h5:hover .anchor .octicon-link::before,
.markdown-body h6:hover .anchor .octicon-link::before {
display: inline-block;
width: 16px;
height: 16px;
content: ' ';
display: inline-block;
content: " ";
}
.markdown-body {
-ms-text-size-adjust: 100%;
-webkit-text-size-adjust: 100%;
line-height: 1.5;
color: $font-color;
font-size: 16px;
line-height: 1.5;
color: $font-color;
word-wrap: break-word;
-ms-text-size-adjust: 100%;
-webkit-text-size-adjust: 100%;
}
.markdown-body details {
@@ -245,15 +247,15 @@ $font-color: #24292e;
}
.markdown-body input {
font: inherit;
margin: 0;
font: inherit;
}
.markdown-body input {
overflow: visible;
}
.markdown-body [type='checkbox'] {
.markdown-body [type="checkbox"] {
box-sizing: border-box;
padding: 0;
}
@@ -289,13 +291,13 @@ $font-color: #24292e;
border-bottom: 1px solid #dfe2e5;
}
.markdown-body hr:after,
.markdown-body hr:before {
.markdown-body hr::after,
.markdown-body hr::before {
display: table;
content: '';
content: "";
}
.markdown-body hr:after {
.markdown-body hr::after {
clear: both;
}
@@ -319,12 +321,12 @@ $font-color: #24292e;
font:
11px SFMono-Regular,
Consolas,
Liberation Mono,
"Liberation Mono",
Menlo,
monospace;
line-height: 10px;
color: #444d56;
vertical-align: middle;
color: #444d56;
background-color: #fafbfc;
border: 1px solid #d1d5da;
border-radius: 3px;
@@ -362,8 +364,8 @@ $font-color: #24292e;
.markdown-body pre,
.markdown-body .line-number {
font-size: 14px !important;
border-radius: 8px;
background-color: #282c34;
border-radius: 8px;
}
.dark {
@@ -540,8 +542,8 @@ $font-color: #24292e;
background-color: #d73a49;
}
.markdown-body .pl-c2:before {
content: '^M';
.markdown-body .pl-c2::before {
content: "^M";
}
.markdown-body .pl-sr .pl-cce {
@@ -604,8 +606,8 @@ $font-color: #24292e;
}
.markdown-body .pl-corl {
text-decoration: underline;
color: #032f62;
text-decoration: underline;
}
.markdown-body .mb-0 {
@@ -692,25 +694,25 @@ $font-color: #24292e;
font:
11px SFMono-Regular,
Consolas,
Liberation Mono,
"Liberation Mono",
Menlo,
monospace;
line-height: 10px;
color: #444d56;
vertical-align: middle;
color: #444d56;
background-color: #fafbfc;
border: 1px solid #d1d5da;
border-radius: 3px;
box-shadow: inset 0 -1px 0 #d1d5da;
}
.markdown-body:after,
.markdown-body:before {
.markdown-body::after,
.markdown-body::before {
display: table;
content: '';
content: "";
}
.markdown-body:after {
.markdown-body::after {
clear: both;
}
@@ -768,11 +770,11 @@ $font-color: #24292e;
}
.markdown-body li {
line-height: 28px;
margin-left: 10px;
font-size: 14px;
line-height: 28px;
word-wrap: break-all;
list-style: disc;
margin-left: 10px;
}
.markdown-body li > p {
@@ -826,19 +828,19 @@ $font-color: #24292e;
}
.markdown-body img {
max-width: 100%;
box-sizing: initial;
max-width: 100%;
cursor: zoom-in;
background-color: #fff;
border: 1px solid #eee;
border: 1px solid var(--art-c-border-2);
cursor: zoom-in;
border: 1px solid var(--fa-c-border-2);
}
.markdown-body img[align='right'] {
.markdown-body img[align="right"] {
padding-left: 20px;
}
.markdown-body img[align='left'] {
.markdown-body img[align="left"] {
padding-right: 20px;
}
@@ -846,7 +848,7 @@ $font-color: #24292e;
padding: 0.2em 0.4em;
margin: 0;
font-size: 85%;
background-color: rgba(27, 31, 35, 0.05);
background-color: rgb(27 31 35 / 5%);
border-radius: 3px;
}
@@ -905,8 +907,7 @@ $font-color: #24292e;
}
.markdown-body .blob-wrapper {
overflow-x: auto;
overflow-y: hidden;
overflow: auto hidden;
}
.markdown-body .blob-wrapper-embedded {
@@ -921,10 +922,10 @@ $font-color: #24292e;
padding-left: 10px;
font-size: 12px;
line-height: 20px;
color: rgba(27, 31, 35, 0.3);
vertical-align: top;
color: rgb(27 31 35 / 30%);
text-align: right;
white-space: nowrap;
vertical-align: top;
cursor: pointer;
-webkit-user-select: none;
-moz-user-select: none;
@@ -933,10 +934,10 @@ $font-color: #24292e;
}
.markdown-body .blob-num:hover {
color: rgba(27, 31, 35, 0.6);
color: rgb(27 31 35 / 60%);
}
.markdown-body .blob-num:before {
.markdown-body .blob-num::before {
content: attr(data-line-number);
}
@@ -962,62 +963,62 @@ $font-color: #24292e;
background: #ffea7f;
}
.markdown-body .tab-size[data-tab-size='1'] {
.markdown-body .tab-size[data-tab-size="1"] {
-moz-tab-size: 1;
tab-size: 1;
}
.markdown-body .tab-size[data-tab-size='2'] {
.markdown-body .tab-size[data-tab-size="2"] {
-moz-tab-size: 2;
tab-size: 2;
}
.markdown-body .tab-size[data-tab-size='3'] {
.markdown-body .tab-size[data-tab-size="3"] {
-moz-tab-size: 3;
tab-size: 3;
}
.markdown-body .tab-size[data-tab-size='4'] {
.markdown-body .tab-size[data-tab-size="4"] {
-moz-tab-size: 4;
tab-size: 4;
}
.markdown-body .tab-size[data-tab-size='5'] {
.markdown-body .tab-size[data-tab-size="5"] {
-moz-tab-size: 5;
tab-size: 5;
}
.markdown-body .tab-size[data-tab-size='6'] {
.markdown-body .tab-size[data-tab-size="6"] {
-moz-tab-size: 6;
tab-size: 6;
}
.markdown-body .tab-size[data-tab-size='7'] {
.markdown-body .tab-size[data-tab-size="7"] {
-moz-tab-size: 7;
tab-size: 7;
}
.markdown-body .tab-size[data-tab-size='8'] {
.markdown-body .tab-size[data-tab-size="8"] {
-moz-tab-size: 8;
tab-size: 8;
}
.markdown-body .tab-size[data-tab-size='9'] {
.markdown-body .tab-size[data-tab-size="9"] {
-moz-tab-size: 9;
tab-size: 9;
}
.markdown-body .tab-size[data-tab-size='10'] {
.markdown-body .tab-size[data-tab-size="10"] {
-moz-tab-size: 10;
tab-size: 10;
}
.markdown-body .tab-size[data-tab-size='11'] {
.markdown-body .tab-size[data-tab-size="11"] {
-moz-tab-size: 11;
tab-size: 11;
}
.markdown-body .tab-size[data-tab-size='12'] {
.markdown-body .tab-size[data-tab-size="12"] {
-moz-tab-size: 12;
tab-size: 12;
}
+18 -30
View File
@@ -5,15 +5,15 @@
* @param {Number} 行数
*/
@mixin ellipsis($rowCount: 1) {
@if $rowCount <=1 {
@if $rowCount <= 1 {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
} @else {
display: -webkit-box;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: $rowCount;
-webkit-box-orient: vertical;
}
@@ -24,19 +24,16 @@
* @param {String} 类型
*/
@mixin userSelect($value: none) {
user-select: $value;
-moz-user-select: $value;
-ms-user-select: $value;
-webkit-user-select: $value;
user-select: $value;
}
// 绝对定位居中
@mixin absoluteCenter() {
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
inset: 0;
margin: auto;
}
@@ -46,10 +43,10 @@
*/
@mixin animation(
$from: (
width: 0px
width: 0,
),
$to: (
width: 100px
width: 100px,
),
$name: mymove,
$animate: mymove 2s 1 linear infinite
@@ -58,6 +55,7 @@
-o-animation: $animate;
animation: $animate;
/* stylelint-disable-next-line at-rule-prelude-no-invalid */
@keyframes #{$name} {
from {
@each $key, $value in $from {
@@ -71,46 +69,34 @@
}
}
}
@-webkit-keyframes #{$name} {
from {
@each $key, $value in $from {
$key: $value;
}
}
to {
@each $key, $value in $to {
$key: $value;
}
}
}
}
// 圆形盒子
@mixin circle($size: 11px, $bg: #fff) {
border-radius: 50%;
width: $size;
height: $size;
line-height: $size;
text-align: center;
background: $bg;
border-radius: 50%;
}
// placeholder
@mixin placeholder($color: #bbb) {
// Firefox
&::-moz-placeholder {
&::placeholder {
color: $color;
opacity: 1;
}
// Internet Explorer 10+
// Internet Explorer 1011
/* stylelint-disable-next-line selector-no-vendor-prefix */
&:-ms-input-placeholder {
color: $color;
}
// Safari and Chrome
// Safari and Chrome (legacy)
/* stylelint-disable-next-line selector-no-vendor-prefix */
&::-webkit-input-placeholder {
color: $color;
}
@@ -124,17 +110,18 @@
@mixin betterTransparentize($color, $alpha) {
$c: rgba($color, $alpha);
$ie_c: ie_hex_str($c);
zoom: 1;
background: rgba($color, 1);
background: $c;
background: transparent \9;
zoom: 1;
-ms-filter: "progid:DXImageTransform.Microsoft.gradient(startColorstr=#{$ie_c}, endColorstr=#{$ie_c})";
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr=#{$ie_c}, endColorstr=#{$ie_c});
-ms-filter: 'progid:DXImageTransform.Microsoft.gradient(startColorstr=#{$ie_c}, endColorstr=#{$ie_c})';
}
//添加浏览器前缀
@mixin browserPrefix($propertyName, $value) {
@each $prefix in -webkit-, -moz-, -ms-, -o-, '' {
@each $prefix in -webkit-, -moz-, -ms-, -o-, "" {
#{$prefix}#{$propertyName}: $value;
}
}
@@ -147,6 +134,7 @@
// 背景滤镜
@mixin backdropBlur() {
--tw-backdrop-blur: blur(30px);
-webkit-backdrop-filter: var(--tw-backdrop-blur) var(--tw-backdrop-brightness)
var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate)
var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate)
+11 -10
View File
@@ -1,33 +1,34 @@
@charset "UTF-8";
/*滚动条*/
/*滚动条整体部分,必须要设置*/
/* 滚动条 */
/* 滚动条整体部分,必须要设置 */
::-webkit-scrollbar {
width: 8px !important;
height: 0 !important;
}
/*滚动条的轨道*/
/* 滚动条的轨道 */
::-webkit-scrollbar-track {
background-color: var(--art-gray-200);
background-color: var(--fa-gray-200);
}
/*滚动条的滑块按钮*/
/* 滚动条的滑块按钮 */
::-webkit-scrollbar-thumb {
background-color: #ccc !important;
border-radius: 5px;
background-color: #cccccc !important;
transition: all 0.2s;
-webkit-transition: all 0.2s;
transition: all 0.2s;
}
::-webkit-scrollbar-thumb:hover {
background-color: #b0abab !important;
}
/*滚动条的上下两端的按钮*/
/* 滚动条的上下两端的按钮 */
::-webkit-scrollbar-button {
height: 0px;
width: 0;
height: 0;
}
.dark {
@@ -36,6 +37,6 @@
}
::-webkit-scrollbar-thumb {
background-color: var(--art-gray-300) !important;
background-color: var(--fa-gray-300) !important;
}
}
@@ -1,4 +1,4 @@
@use 'sass:map';
@use "sass:map";
// === 变量区域 ===
$transition: (
@@ -18,10 +18,10 @@ $transition: (
}
// 变量简写
$duration: transition-config('duration');
$distance: transition-config('distance');
$easing: transition-config('easing');
$fade-easing: transition-config('fade-easing');
$duration: transition-config("duration");
$distance: transition-config("distance");
$easing: transition-config("easing");
$fade-easing: transition-config("fade-easing");
// === 动画类 ===
@@ -49,13 +49,13 @@ $fade-easing: transition-config('fade-easing');
$distance-x: 0;
$distance-y: 0;
@if $direction == 'left' {
@if $direction == "left" {
$distance-x: -$distance;
} @else if $direction == 'right' {
} @else if $direction == "right" {
$distance-x: $distance;
} @else if $direction == 'top' {
} @else if $direction == "top" {
$distance-y: -$distance;
} @else if $direction == 'bottom' {
} @else if $direction == "bottom" {
$distance-y: $distance;
}
@@ -91,14 +91,17 @@ $fade-easing: transition-config('fade-easing');
// 滑动动画方向类
.slide-left {
@include slide-transition('left');
@include slide-transition("left");
}
.slide-right {
@include slide-transition('right');
@include slide-transition("right");
}
.slide-top {
@include slide-transition('top');
@include slide-transition("top");
}
.slide-bottom {
@include slide-transition('bottom');
@include slide-transition("bottom");
}
+60 -61
View File
@@ -1,72 +1,71 @@
@import 'tailwindcss';
@import "tailwindcss";
@custom-variant dark (&:where(.dark, .dark *));
/* ==================== Light Mode Variables ==================== */
:root {
/* Base Colors */
--art-color: #ffffff;
--fa-color: #fff;
--theme-color: var(--main-color);
/* Theme Colors - OKLCH Format */
--art-primary: oklch(0.7 0.23 260);
--art-secondary: oklch(0.72 0.19 231.6);
--art-error: oklch(0.73 0.15 25.3);
--art-info: oklch(0.58 0.03 254.1);
--art-success: oklch(0.78 0.17 166.1);
--art-warning: oklch(0.78 0.14 75.5);
--art-danger: oklch(0.68 0.22 25.3);
--fa-primary: oklch(70% 0.23 260deg);
--fa-secondary: oklch(72% 0.19 231.6deg);
--fa-error: oklch(73% 0.15 25.3deg);
--fa-info: oklch(58% 0.03 254.1deg);
--fa-success: oklch(78% 0.17 166.1deg);
--fa-warning: oklch(78% 0.14 75.5deg);
--fa-danger: oklch(68% 0.22 25.3deg);
/* Gray Scale - Light Mode */
--art-gray-100: #f9fafb;
--art-gray-200: #f2f4f5;
--art-gray-300: #e6eaeb;
--art-gray-400: #dbdfe1;
--art-gray-500: #949eb7;
--art-gray-600: #7987a1;
--art-gray-700: #4d5875;
--art-gray-800: #383853;
--art-gray-900: #323251;
--fa-gray-100: #f9fafb;
--fa-gray-200: #f2f4f5;
--fa-gray-300: #e6eaeb;
--fa-gray-400: #dbdfe1;
--fa-gray-500: #949eb7;
--fa-gray-600: #7987a1;
--fa-gray-700: #4d5875;
--fa-gray-800: #383853;
--fa-gray-900: #323251;
/* Border Colors */
--art-card-border: rgba(0, 0, 0, 0.08);
--fa-card-border: rgb(0 0 0 / 8%);
--default-border: #e2e8ee;
--default-border-dashed: #dbdfe9;
/* Background Colors */
--default-bg-color: #fafbfc;
--default-box-color: #ffffff;
--default-box-color: #fff;
/* Hover Color */
--art-hover-color: #edeff0;
--fa-hover-color: #edeff0;
/* Active Color */
--art-active-color: #f2f4f5;
--fa-active-color: #f2f4f5;
/* Element Component Active Color */
--art-el-active-color: #f2f4f5;
--fa-el-active-color: #f2f4f5;
}
/* ==================== Dark Mode Variables ==================== */
.dark {
/* Base Colors */
--art-color: #000000;
--fa-color: #000;
/* Gray Scale - Dark Mode */
--art-gray-100: #110f0f;
--art-gray-200: #17171c;
--art-gray-300: #393946;
--art-gray-400: #505062;
--art-gray-500: #73738c;
--art-gray-600: #8f8fa3;
--art-gray-700: #ababba;
--art-gray-800: #c7c7d1;
--art-gray-900: #e3e3e8;
--fa-gray-100: #110f0f;
--fa-gray-200: #17171c;
--fa-gray-300: #393946;
--fa-gray-400: #505062;
--fa-gray-500: #73738c;
--fa-gray-600: #8f8fa3;
--fa-gray-700: #ababba;
--fa-gray-800: #c7c7d1;
--fa-gray-900: #e3e3e8;
/* Border Colors */
--art-card-border: rgba(255, 255, 255, 0.08);
--default-border: rgba(255, 255, 255, 0.1);
--fa-card-border: rgb(255 255 255 / 8%);
--default-border: rgb(255 255 255 / 10%);
--default-border-dashed: #363843;
/* Background Colors */
@@ -74,13 +73,13 @@
--default-box-color: #161618;
/* Hover Color */
--art-hover-color: #252530;
--fa-hover-color: #252530;
/* Active Color */
--art-active-color: #202226;
--fa-active-color: #202226;
/* Element Component Active Color */
--art-el-active-color: #2e2e38;
--fa-el-active-color: #2e2e38;
}
/* ==================== Tailwind Theme Configuration ==================== */
@@ -92,33 +91,33 @@
--color-theme: var(--theme-color);
/* Hover Color */
--color-hover-color: var(--art-hover-color);
--color-hover-color: var(--fa-hover-color);
/* Active Color */
--color-active-color: var(--art-active-color);
--color-active-color: var(--fa-active-color);
/* Active Color */
--color-el-active-color: var(--art-active-color);
--color-el-active-color: var(--fa-active-color);
/* ElementPlus Theme Colors */
--color-primary: var(--art-primary);
--color-secondary: var(--art-secondary);
--color-error: var(--art-error);
--color-info: var(--art-info);
--color-success: var(--art-success);
--color-warning: var(--art-warning);
--color-danger: var(--art-danger);
--color-primary: var(--fa-primary);
--color-secondary: var(--fa-secondary);
--color-error: var(--fa-error);
--color-info: var(--fa-info);
--color-success: var(--fa-success);
--color-warning: var(--fa-warning);
--color-danger: var(--fa-danger);
/* Gray Scale Colors (Auto-adapts to dark mode) */
--color-g-100: var(--art-gray-100);
--color-g-200: var(--art-gray-200);
--color-g-300: var(--art-gray-300);
--color-g-400: var(--art-gray-400);
--color-g-500: var(--art-gray-500);
--color-g-600: var(--art-gray-600);
--color-g-700: var(--art-gray-700);
--color-g-800: var(--art-gray-800);
--color-g-900: var(--art-gray-900);
--color-g-100: var(--fa-gray-100);
--color-g-200: var(--fa-gray-200);
--color-g-300: var(--fa-gray-300);
--color-g-400: var(--fa-gray-400);
--color-g-500: var(--fa-gray-500);
--color-g-600: var(--fa-gray-600);
--color-g-700: var(--fa-gray-700);
--color-g-800: var(--fa-gray-800);
--color-g-900: var(--fa-gray-900);
}
/* ==================== Custom Border Radius Utilities ==================== */
@@ -187,8 +186,8 @@
/* ==================== Custom Component Classes ==================== */
@layer components {
/* Art Card Header Component */
.art-card-header {
/* Fa Card Header Component */
.fa-card-header {
@apply flex justify-between pr-6 pb-1;
.title {
@@ -6,10 +6,6 @@ $bg-animation-duration: 0.5s;
html {
--bg-animation-color: $bg-animation-color-light;
&.dark {
--bg-animation-color: $bg-animation-color-dark;
}
// View transition styles
&::view-transition-old(*) {
animation: none;
@@ -28,6 +24,8 @@ html {
}
&.dark {
--bg-animation-color: $bg-animation-color-dark;
&::view-transition-old(*) {
animation: clip $bg-animation-duration ease-in reverse both;
}
@@ -1,8 +1,7 @@
.hljs {
display: block;
overflow-x: auto;
padding: 0.5em;
overflow-x: auto;
color: #a6accd;
}
+8 -8
View File
@@ -1,23 +1,23 @@
// 重置默认样式
@use './core/reset.scss';
@use "./core/reset";
// 应用全局样式
@use './core/app.scss';
@use "./core/app";
// Element Plus 样式优化
@use './core/el-ui.scss';
@use "./core/el-ui";
// Element Plus 暗黑主题
@use './core/el-dark.scss';
@use "./core/el-dark";
// 暗黑主题样式优化
@use './core/dark.scss';
@use "./core/dark";
// 路由切换动画
@use './core/router-transition';
@use "./core/router-transition";
// 主题切换过渡优化
@use './core/theme-change.scss';
@use "./core/theme-change";
// 主题切换圆形扩散动画
@use './core/theme-animation.scss';
@use "./core/theme-animation";
+2 -23
View File
@@ -1,26 +1,4 @@
declare global {
/**
*
*/
interface TagView {
/** 页签名称 */
name: string;
/** 页签标题 */
title: string;
/** 页签路由路径 */
path: string;
/** 页签路由完整路径 */
fullPath: string;
/** 页签图标 */
icon?: string;
/** 是否固定页签 */
affix?: boolean;
/** 是否开启缓存 */
keepAlive?: boolean;
/** 路由查询参数 */
query?: any;
}
/**
*
*/
@@ -133,7 +111,8 @@ declare global {
}
/**
*
* `data`
* `useTable` `@utils/table` `defaultResponseAdapter` ApiResponse
*/
interface PageResult<T = any> {
items: T[];
+8
View File
@@ -67,6 +67,12 @@ declare module "vue-router" {
*/
keepAlive?: boolean;
/**
* true KeepAlive `:key` 使 `fullPath`query/hash
* `name + params` query onMounted / useTable immediate
*/
remountOnFullPath?: boolean;
/**
* router component
*/
@@ -125,6 +131,8 @@ export interface RouteMeta extends Record<string | number | symbol, unknown> {
parentPath?: string;
/** 静态壳层菜单(侧边栏可点,组件由静态路由提供) */
shellRoute?: boolean;
/** @see RouteMetavue-router 模块扩展) */
remountOnFullPath?: boolean;
}
/**
+1 -1
View File
@@ -549,7 +549,7 @@ export interface QuickLink {
*/
class QuickStartManager {
/** 本地存储键名 */
private storageKey = "quick-stfa-links";
private storageKey = "quick-start-links";
/** 监听器列表 */
private listeners: Array<(links: QuickLink[]) => void> = [];
-140
View File
@@ -1,140 +0,0 @@
/**
* Axios /
*/
import axios, {
type AxiosInstance,
type AxiosResponse,
type AxiosError,
type InternalAxiosRequestConfig,
} from "axios";
import { ElMessage } from "element-plus";
import { Auth, redirectToLogin } from "@utils/auth";
import { ResultEnum } from "@/enums/api/result.enum";
import { $t } from "@/locales";
import { defaultConfig, NO_AUTH_FLAG } from "./config";
import { ApiStatus, HttpError } from "./error";
const handleRequest = (config: InternalAxiosRequestConfig) => {
const accessToken = Auth.getAccessToken();
const auth = config.headers.Authorization;
if (auth === NO_AUTH_FLAG) {
delete config.headers.Authorization;
return config;
}
if (!auth && accessToken) {
config.headers.Authorization = `Bearer ${accessToken}`;
}
return config;
};
const handleRequestError = (error: unknown) => {
const msg = error instanceof Error ? error.message : String(error);
ElMessage.error(msg);
return Promise.reject(error);
};
const handleResponse = (response: AxiosResponse<ApiResponse>) => {
if (response.config.responseType === "blob") {
return response;
}
const data = response.data;
if (data.code !== ResultEnum.SUCCESS) {
ElMessage.error(data.msg);
return Promise.reject(response);
}
if (
response.config.method?.toUpperCase() !== "GET" &&
!response.config.url?.includes("login") &&
!response.config.url?.includes("logout")
) {
ElMessage.success(data.msg);
}
return response;
};
const handleResponseError = async (error: AxiosError<ApiResponse>) => {
if (!error.response) {
let errorMessage = $t("httpMsg.networkError");
if (error.message?.includes("ECONNREFUSED")) {
errorMessage = "服务器连接失败,请检查后端服务是否正常运行";
} else if (error.message?.includes("timeout")) {
errorMessage = "请求超时,请稍后重试";
} else if (error.message?.includes("Network Error")) {
errorMessage = "网络连接错误,请检查您的网络设置";
}
console.error("网络请求失败:", error);
ElMessage.error(errorMessage);
return Promise.reject(new Error(errorMessage));
}
const data = error.response?.data;
if (error.response?.config.responseType === "blob" && error.response.data instanceof Blob) {
try {
const text = await new Response(error.response.data).text();
const jsonData: ApiResponse = JSON.parse(text);
if (jsonData.code === ResultEnum.ERROR) {
ElMessage.error(jsonData.msg || "请求错误");
return Promise.reject(new Error(jsonData.msg || "请求错误"));
}
if (jsonData.code === ResultEnum.EXCEPTION) {
ElMessage.error(jsonData.msg || "服务异常");
return Promise.reject(new Error(jsonData.msg || "服务异常"));
}
} catch (e) {
console.error("请求异常:", e);
ElMessage.error("数据解析失败");
return Promise.reject(new Error("数据解析失败"));
}
}
const status = error.response.status;
const hasApiCode =
data !== undefined &&
data !== null &&
typeof data === "object" &&
"code" in data &&
typeof (data as ApiResponse).code === "number";
if (status === 401 && !hasApiCode) {
await redirectToLogin("登录已失效,请重新登录");
return Promise.reject(new HttpError("Unauthorized", ApiStatus.unauthorized));
}
if (data?.code === ResultEnum.TOKEN_EXPIRED) {
await redirectToLogin("登录已过期,请重新登录");
const msg = data.msg || "登录已过期,请重新登录";
return Promise.reject(new HttpError(msg, ApiStatus.unauthorized));
}
if (data?.code === ResultEnum.ERROR) {
ElMessage.error(data.msg || "请求错误");
return Promise.reject(new Error(data.msg || "请求错误"));
}
if (data?.code === ResultEnum.UNAUTHORIZED) {
ElMessage.error(data.msg || "暂无权限");
return Promise.reject(new Error(data.msg || "请求错误"));
}
if (data?.code === ResultEnum.EXCEPTION) {
ElMessage.error(data.msg || "服务异常");
return Promise.reject(new Error(data.msg || "服务异常"));
}
ElMessage.error("请求处理失败,请稍后重试");
return Promise.reject(new Error("请求处理失败"));
};
const httpRequest: AxiosInstance = axios.create(defaultConfig);
httpRequest.interceptors.request.use(handleRequest, handleRequestError);
httpRequest.interceptors.response.use(handleResponse, handleResponseError);
export default httpRequest;
-24
View File
@@ -1,24 +0,0 @@
/**
* Axios
*/
import type { AxiosRequestConfig } from "axios";
import * as qs from "qs";
export const defaultConfig: AxiosRequestConfig = {
baseURL: import.meta.env.VITE_APP_BASE_API,
timeout: Number(import.meta.env.VITE_TIMEOUT) || 15000,
headers: { "Content-Type": "application/json;charset=utf-8" },
paramsSerializer: (params) => qs.stringify(params, { indices: false }),
};
/** 跳过鉴权:与单接口 `headers.Authorization` 约定一致 */
export const NO_AUTH_FLAG = "no-auth";
export type HttpMethod = "GET" | "POST" | "PUT" | "DELETE" | "PATCH" | "HEAD" | "OPTIONS";
export interface ExtendedRequestConfig extends AxiosRequestConfig {
skipAuth?: boolean;
showSuccessMessage?: boolean;
showErrorMessage?: boolean;
}
-134
View File
@@ -1,134 +0,0 @@
/**
* HTTP HttpError
*/
import type { AxiosError } from "axios";
import { $t } from "@/locales";
export enum ApiStatus {
success = 200,
error = 400,
unauthorized = 401,
forbidden = 403,
notFound = 404,
methodNotAllowed = 405,
requestTimeout = 408,
internalServerError = 500,
notImplemented = 501,
badGateway = 502,
serviceUnavailable = 503,
gatewayTimeout = 504,
httpVersionNotSupported = 505,
}
export interface ErrorLogData {
code: number;
message: string;
data?: unknown;
timestamp: string;
url?: string;
method?: string;
stack?: string;
}
export class HttpError extends Error {
public readonly code: number;
public readonly data?: unknown;
public readonly timestamp: string;
public readonly url?: string;
public readonly method?: string;
constructor(
message: string,
code: number,
options?: {
data?: unknown;
url?: string;
method?: string;
}
) {
super(message);
this.name = "HttpError";
this.code = code;
this.data = options?.data;
this.timestamp = new Date().toISOString();
this.url = options?.url;
this.method = options?.method;
}
public toLogData(): ErrorLogData {
return {
code: this.code,
message: this.message,
data: this.data,
timestamp: this.timestamp,
url: this.url,
method: this.method,
stack: this.stack,
};
}
}
const getErrorMessage = (status: number): string => {
const errorMap: Record<number, string> = {
[ApiStatus.unauthorized]: "httpMsg.unauthorized",
[ApiStatus.forbidden]: "httpMsg.forbidden",
[ApiStatus.notFound]: "httpMsg.notFound",
[ApiStatus.methodNotAllowed]: "httpMsg.methodNotAllowed",
[ApiStatus.requestTimeout]: "httpMsg.requestTimeout",
[ApiStatus.internalServerError]: "httpMsg.internalServerError",
[ApiStatus.badGateway]: "httpMsg.badGateway",
[ApiStatus.serviceUnavailable]: "httpMsg.serviceUnavailable",
[ApiStatus.gatewayTimeout]: "httpMsg.gatewayTimeout",
};
return $t(errorMap[status] || "httpMsg.internalServerError");
};
export function handleError(error: AxiosError<ApiResponse>): never {
if (error.code === "ERR_CANCELED") {
console.warn("Request cancelled:", error.message);
throw new HttpError($t("httpMsg.requestCancelled"), ApiStatus.error);
}
const statusCode = error.response?.status;
const errorMessage = error.response?.data?.msg || error.message;
const requestConfig = error.config;
if (!error.response) {
throw new HttpError($t("httpMsg.networkError"), ApiStatus.error, {
url: requestConfig?.url,
method: requestConfig?.method?.toUpperCase(),
});
}
const message = statusCode
? getErrorMessage(statusCode)
: errorMessage || $t("httpMsg.requestFailed");
throw new HttpError(message, statusCode || ApiStatus.error, {
data: error.response.data,
url: requestConfig?.url,
method: requestConfig?.method?.toUpperCase(),
});
}
export function showError(error: HttpError, showMessage: boolean = true): void {
import("element-plus").then(({ ElMessage: EM }) => {
if (showMessage) {
EM.error(error.message);
}
});
console.error("[HTTP Error]", error.toLogData());
}
export function showSuccess(message: string, showMessage: boolean = true): void {
import("element-plus").then(({ ElMessage: EM }) => {
if (showMessage) {
EM.success(message);
}
});
}
export const isHttpError = (error: unknown): error is HttpError => {
return error instanceof HttpError;
};
+281 -6
View File
@@ -1,10 +1,285 @@
/**
* HTTP / / Axios
*
* @module utils/http
* HTTPAxios
*/
export * from "./config";
export * from "./error";
export { default } from "./client";
import axios, {
type AxiosError,
type AxiosInstance,
type AxiosRequestConfig,
type AxiosResponse,
type InternalAxiosRequestConfig,
} from "axios";
import qs from "qs";
import { ElMessage } from "element-plus";
import { ResultEnum } from "@/enums/api/result.enum";
import { Auth } from "@/utils/auth";
import { redirectToLogin } from "@/utils/auth";
import { $t } from "@/locales";
// --- 配置常量 -----------------------------------------------------------------
/** 跳过鉴权:与单接口 `headers.Authorization` 约定一致 */
export const NO_AUTH_FLAG = "no-auth";
export interface ExtendedRequestConfig extends AxiosRequestConfig {
skipAuth?: boolean;
showSuccessMessage?: boolean;
showErrorMessage?: boolean;
}
// --- 语义状态码与 HttpError ----------------------------------------------------
export enum ApiStatus {
success = 200,
error = 400,
unauthorized = 401,
forbidden = 403,
notFound = 404,
methodNotAllowed = 405,
requestTimeout = 408,
internalServerError = 500,
notImplemented = 501,
badGateway = 502,
serviceUnavailable = 503,
gatewayTimeout = 504,
httpVersionNotSupported = 505,
}
export interface ErrorLogData {
code: number;
message: string;
data?: unknown;
timestamp: string;
url?: string;
method?: string;
stack?: string;
}
export class HttpError extends Error {
public readonly code: number;
public readonly data?: unknown;
public readonly timestamp: string;
public readonly url?: string;
public readonly method?: string;
constructor(
message: string,
code: number,
options?: {
data?: unknown;
url?: string;
method?: string;
}
) {
super(message);
this.name = "HttpError";
this.code = code;
this.data = options?.data;
this.timestamp = new Date().toISOString();
this.url = options?.url;
this.method = options?.method;
}
public toLogData(): ErrorLogData {
return {
code: this.code,
message: this.message,
data: this.data,
timestamp: this.timestamp,
url: this.url,
method: this.method,
stack: this.stack,
};
}
}
const getErrorMessage = (status: number): string => {
const errorMap: Record<number, string> = {
[ApiStatus.unauthorized]: "httpMsg.unauthorized",
[ApiStatus.forbidden]: "httpMsg.forbidden",
[ApiStatus.notFound]: "httpMsg.notFound",
[ApiStatus.methodNotAllowed]: "httpMsg.methodNotAllowed",
[ApiStatus.requestTimeout]: "httpMsg.requestTimeout",
[ApiStatus.internalServerError]: "httpMsg.internalServerError",
[ApiStatus.badGateway]: "httpMsg.badGateway",
[ApiStatus.serviceUnavailable]: "httpMsg.serviceUnavailable",
[ApiStatus.gatewayTimeout]: "httpMsg.gatewayTimeout",
};
return $t(errorMap[status] || "httpMsg.internalServerError");
};
export function handleError(error: AxiosError<ApiResponse>): never {
if (error.code === "ERR_CANCELED") {
console.warn("Request cancelled:", error.message);
throw new HttpError($t("httpMsg.requestCancelled"), ApiStatus.error);
}
const statusCode = error.response?.status;
const errorMessage = error.response?.data?.msg || error.message;
const requestConfig = error.config;
if (!error.response) {
throw new HttpError($t("httpMsg.networkError"), ApiStatus.error, {
url: requestConfig?.url,
method: requestConfig?.method?.toUpperCase(),
});
}
const message = statusCode
? getErrorMessage(statusCode)
: errorMessage || $t("httpMsg.requestFailed");
throw new HttpError(message, statusCode || ApiStatus.error, {
data: error.response.data,
url: requestConfig?.url,
method: requestConfig?.method?.toUpperCase(),
});
}
export function showError(error: HttpError, showMessage: boolean = true): void {
if (showMessage) {
ElMessage.error(error.message);
}
console.error("[HTTP Error]", error.toLogData());
}
export function showSuccess(message: string, showMessage: boolean = true): void {
if (showMessage) {
ElMessage.success(message);
}
}
export const isHttpError = (error: unknown): error is HttpError => {
return error instanceof HttpError;
};
// --- Axios 实例 ---------------------------------------------------------------
const request: AxiosInstance = axios.create({
baseURL: import.meta.env.VITE_APP_BASE_API,
timeout: Number(import.meta.env.VITE_TIMEOUT) || 15000,
headers: { "Content-Type": "application/json;charset=utf-8" },
paramsSerializer: (params) => qs.stringify(params, { indices: false }),
});
request.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
const accessToken = Auth.getAccessToken();
const auth = config.headers.Authorization;
if (auth === NO_AUTH_FLAG) {
delete config.headers.Authorization;
return config;
}
if (!auth && accessToken) {
config.headers.Authorization = `Bearer ${accessToken}`;
}
return config;
},
(error) => {
const msg = error instanceof Error ? error.message : String(error);
ElMessage.error(msg);
return Promise.reject(error);
}
);
request.interceptors.response.use(
(response: AxiosResponse<ApiResponse>) => {
if (response.config.responseType === "blob") {
return response;
}
const data = response.data;
if (data.code !== ResultEnum.SUCCESS) {
ElMessage.error(data.msg);
return Promise.reject(response);
}
if (
response.config.method?.toUpperCase() !== "GET" &&
!response.config.url?.includes("login") &&
!response.config.url?.includes("logout")
) {
ElMessage.success(data.msg);
}
return response;
},
async (error: AxiosError<ApiResponse>) => {
if (!error.response) {
let errorMessage = "网络连接异常";
if (error.message?.includes("ECONNREFUSED")) {
errorMessage = "服务器连接失败,请检查后端服务是否正常运行";
} else if (error.message?.includes("timeout")) {
errorMessage = "请求超时,请稍后重试";
} else if (error.message?.includes("Network Error")) {
errorMessage = "网络连接错误,请检查您的网络设置";
}
console.error("网络请求失败:", error);
ElMessage.error(errorMessage);
return Promise.reject(new Error(errorMessage));
}
const data = error.response?.data;
if (error.response?.config.responseType === "blob" && error.response.data instanceof Blob) {
try {
const text = await new Response(error.response.data).text();
const jsonData: ApiResponse = JSON.parse(text);
if (jsonData.code === ResultEnum.ERROR) {
ElMessage.error(jsonData.msg || "请求错误");
return Promise.reject(new Error(jsonData.msg || "请求错误"));
} else if (jsonData.code === ResultEnum.EXCEPTION) {
ElMessage.error(jsonData.msg || "服务异常");
return Promise.reject(new Error(jsonData.msg || "服务异常"));
}
} catch (e) {
console.error("请求异常:", e);
ElMessage.error("数据解析失败");
return Promise.reject(new Error("数据解析失败"));
}
}
const status = error.response.status;
const hasApiCode =
data !== undefined &&
data !== null &&
typeof data === "object" &&
"code" in data &&
typeof (data as ApiResponse).code === "number";
if (status === 401 && !hasApiCode) {
await redirectToLogin("登录已失效,请重新登录");
return Promise.reject(new HttpError("Unauthorized", ApiStatus.unauthorized));
}
if (data?.code === ResultEnum.TOKEN_EXPIRED) {
await redirectToLogin("登录已过期,请重新登录");
const msg = data.msg || "登录已过期,请重新登录";
return Promise.reject(new HttpError(msg, ApiStatus.unauthorized));
} else if (data?.code === ResultEnum.ERROR) {
ElMessage.error(data.msg || "请求错误");
return Promise.reject(new Error(data.msg || "请求错误"));
} else if (data?.code === ResultEnum.UNAUTHORIZED) {
ElMessage.error(data.msg || "暂无权限");
return Promise.reject(new Error(data.msg || "请求错误"));
} else if (data?.code === ResultEnum.EXCEPTION) {
ElMessage.error(data.msg || "服务异常");
return Promise.reject(new Error(data.msg || "服务异常"));
} else {
ElMessage.error("请求处理失败,请稍后重试");
return Promise.reject(new Error("请求处理失败"));
}
}
);
export default request;
export type { AxiosInstance } from "axios";
+5 -5
View File
@@ -13,7 +13,7 @@ export function isElementPlusStoredIcon(icon?: string | null): boolean {
return !!s && s.startsWith("el-icon");
}
/** 与历史 `MenuSearch` 中 `resolveEpMenuIcon` 一致:`pie-chart` → `PieChart` */
/** `el-icon-*` 主体转 PascalCase,与侧栏 / 搜索里 Element Plus 图标解析一致:`pie-chart` → `PieChart` */
function kebabSnakeBodyToPascalKey(body: string): string {
return body
.split(/[-_]/)
@@ -25,7 +25,7 @@ function kebabSnakeBodyToPascalKey(body: string): string {
/**
* Element Plus null Iconify / Remix
* `layouts/old/components/Menu/components/MenuItemContent.vue`el-icon /
* `MenuSearch` `el-icon-*` Pascal
* `el-icon-*` Pascal
*/
export function resolveElementPlusIconComponent(icon?: string | null): Component | null {
const ic = icon?.trim();
@@ -171,7 +171,7 @@ const REMIX_BY_NAME: Record<string, string> = {
upload_folder: "ri:folder-upload-line",
"upload-folder": "ri:folder-upload-line",
user: "ri:user-line",
visitor: "ri:user-hefa-line",
visitor: "ri:user-heart-line",
vite: "ri:rocket-line",
vue: "ri:vuejs-line",
wechat: "ri:wechat-fill",
@@ -179,7 +179,7 @@ const REMIX_BY_NAME: Record<string, string> = {
people: "ri:team-line",
"menu-about": "ri:information-line",
"menu-analyse": "ri:line-chfa-line",
"menu-analyse": "ri:line-chart-line",
"menu-crud": "ri:database-2-line",
"menu-detail": "ri:file-list-line",
"menu-document": "ri:file-text-line",
@@ -191,7 +191,7 @@ const REMIX_BY_NAME: Record<string, string> = {
"menu-home": "ri:home-4-line",
"menu-layout": "ri:layout-line",
"menu-multi": "ri:layout-grid-line",
"menu-result": "ri:bar-chfa-box-line",
"menu-result": "ri:bar-chart-box-line",
"menu-system": "ri:settings-3-line",
"menu-table": "ri:table-line",
"menu-test": "ri:test-tube-line",
+34 -13
View File
@@ -1,5 +1,3 @@
/** Navigation helpers (flattened). */
import type { RouteLocationNormalized, RouteRecordRaw } from "vue-router";
import { router } from "@/router";
import type { AppRouteRecord, AppRouteRecord as AppRouteRecordFromTypes } from "@/types";
@@ -10,8 +8,12 @@ import { useSettingsStore } from "@stores/modules/setting.store";
import { IframeRouteManager } from "@/router";
import { useCommon } from "@/hooks/core/useCommon";
/**
* `setWorktab``setPageTitle`
*/
export type AppRouteRecordRaw = RouteRecordRaw & { hidden?: boolean };
/** 浏览器标题:meta.title 经 i18n 键或原文 + 站点名 */
export const setPageTitle = (to: RouteLocationNormalized): void => {
const { title } = to.meta;
if (!title) return;
@@ -83,11 +85,18 @@ export const handleMenuJump = (item: AppRouteRecord, jumpToFirst: boolean = fals
return router.push(firstChild.path);
};
/**
* `meta.isHideTab``settings.showWorkTab`
* - `opened`openTab
* - `syncCurrentFromRoute` `opened` `current`
*/
export const setWorktab = (to: RouteLocationNormalized): void => {
const worktabStore = useWorktabStore();
const { meta, path, name, params, query } = to;
if (meta.isHideTab) return;
const routeNameStr = name != null ? String(name) : "";
if (isIframe(path)) {
const iframeRoute = IframeRouteManager.getInstance().findByPath(to.path);
if (!iframeRoute?.meta) return;
@@ -96,8 +105,8 @@ export const setWorktab = (to: RouteLocationNormalized): void => {
title: iframeRoute.meta.title,
icon: meta.icon as string,
path,
name: name as string,
keepAlive: meta.keepAlive as boolean,
name: routeNameStr,
keepAlive: meta.keepAlive !== false,
params,
query,
});
@@ -105,16 +114,28 @@ export const setWorktab = (to: RouteLocationNormalized): void => {
return;
}
const tabPayload = {
title: (meta.title as string) || "",
icon: meta.icon as string,
path,
name: routeNameStr,
keepAlive: meta.keepAlive !== false,
params,
query,
fixedTab: meta.fixedTab as boolean,
};
if (useSettingsStore().showWorkTab || path === useCommon().homePath.value) {
worktabStore.openTab({
title: meta.title as string,
icon: meta.icon as string,
path,
name: name as string,
keepAlive: meta.keepAlive as boolean,
params,
query,
fixedTab: meta.fixedTab as boolean,
worktabStore.openTab(tabPayload);
} else {
worktabStore.syncCurrentFromRoute({
path: tabPayload.path,
name: tabPayload.name,
title: tabPayload.title,
icon: tabPayload.icon,
keepAlive: tabPayload.keepAlive,
params: tabPayload.params,
query: tabPayload.query,
});
}
};
+136 -119
View File
@@ -1,31 +1,90 @@
/** Table helpers (flattened). */
/**
* `useTable`
*
* - `PageResult` `data` `defaultResponseAdapter`
* - `TableCache` hash + `useTable.clearCache` 使
*/
import { h } from "vue";
import type { VNode } from "vue";
import { ElTooltip } from "element-plus";
import { hash } from "ohash";
import FaButtonMore from "@/components/forms/fa-button-more/index.vue";
import ArtButtonMore from "@/components/forms/fa-button-more/index.vue";
import type { ButtonMoreItem } from "@/components/forms/fa-button-more/index.vue";
import FaButtonTable from "@/components/forms/fa-button-table/index.vue";
import ArtButtonTable from "@/components/forms/fa-button-table/index.vue";
// -----------------------------
// Config
// -----------------------------
// --- 全局分页字段名(与 PageQuery 对齐) ---
/** 仅保留与全局 `PageQuery` / `PageResult` 对齐的分页参数字段名(供 useTable 合并请求参数) */
export const tableConfig = {
recordFields: ["list", "data", "records", "items", "result", "rows"],
totalFields: ["total", "count"],
currentFields: ["current", "page", "pageNum", "page_no"],
sizeFields: ["size", "pageSize", "limit", "page_size"],
paginationKey: {
current: "current",
size: "size",
current: "page_no",
size: "page_size",
},
};
} as const;
// -----------------------------
// Cache
// -----------------------------
/** 与 `global.d.ts` 中 `PageResult` 字段一致;分页列表接口必须返回该结构(或包在 ApiResponse.data 内) */
function isPageResultPayload(o: unknown): o is PageResult<unknown> {
if (!o || typeof o !== "object" || Array.isArray(o)) return false;
const r = o as Record<string, unknown>;
return (
Array.isArray(r.items) &&
typeof r.total === "number" &&
typeof r.page_no === "number" &&
typeof r.page_size === "number" &&
typeof r.has_next === "boolean"
);
}
/** 将常见分页形状(含 MyBatis-Plus / 旧字段名)规范为全局 `PageResult` */
function normalizePageResultLike(raw: unknown): PageResult<unknown> | null {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
const o = raw as Record<string, unknown>;
const items =
(Array.isArray(o.items) && o.items) ||
(Array.isArray(o.records) && o.records) ||
(Array.isArray(o.list) && o.list) ||
(Array.isArray(o.rows) && o.rows) ||
null;
if (!items) return null;
const totalRaw = o.total;
const total =
typeof totalRaw === "number" ? totalRaw : typeof totalRaw === "string" ? Number(totalRaw) : NaN;
if (!Number.isFinite(total)) return null;
const pageNoRaw = o.page_no ?? o.current ?? o.page;
const page_no =
typeof pageNoRaw === "number"
? pageNoRaw
: typeof pageNoRaw === "string"
? Number(pageNoRaw)
: NaN;
if (!Number.isFinite(page_no)) return null;
const pageSizeRaw = o.page_size ?? o.size ?? o.limit ?? o.pageSize;
const page_size =
typeof pageSizeRaw === "number"
? pageSizeRaw
: typeof pageSizeRaw === "string"
? Number(pageSizeRaw)
: NaN;
if (!Number.isFinite(page_size)) return null;
let has_next: boolean;
if (typeof o.has_next === "boolean") {
has_next = o.has_next;
} else if (typeof o.hasNext === "boolean") {
has_next = o.hasNext;
} else {
has_next = page_no * page_size < total;
}
return { items, total, page_no, page_size, has_next };
}
// --- 缓存 ---
export enum CacheInvalidationStrategy {
CLEAR_ALL = "clear_all",
@@ -34,12 +93,13 @@ export enum CacheInvalidationStrategy {
KEEP_ALL = "keep_all",
}
/** useTable 内部使用的规范化分页响应(由 PageResult 映射而来) */
export interface ApiResponse<T = unknown> {
records?: T[];
data?: T[];
total?: number;
records: T[];
total: number;
current?: number;
size?: number;
has_next?: boolean;
[key: string]: unknown;
}
@@ -78,7 +138,7 @@ export class TableCache<T> {
const searchKeys = Object.keys(params).filter(
(key) =>
!["current", "size", "total"].includes(key) &&
!["current", "size", "total", "page_no", "page_size"].includes(key) &&
params[key] !== undefined &&
params[key] !== "" &&
params[key] !== null
@@ -91,7 +151,9 @@ export class TableCache<T> {
tags.add("search:default");
}
tags.add(`pagination:${params.size || 10}`);
const ps = params as Record<string, unknown>;
const pageSizeVal = ps.page_size ?? ps.size;
tags.add(`pagination:${typeof pageSizeVal === "number" ? pageSizeVal : 10}`);
tags.add("pagination");
return tags;
}
@@ -215,9 +277,7 @@ export class TableCache<T> {
}
}
// -----------------------------
// Utils
// -----------------------------
// --- 响应解析与防抖 ---
function unwrapAxiosResponseBody(response: unknown): unknown {
if (response === null || typeof response !== "object") return response;
@@ -236,6 +296,41 @@ function unwrapAxiosResponseBody(response: unknown): unknown {
return response;
}
/**
* axios body `PageResult`
* `PageResult` `ApiResponse.data` `records/current/size`
*/
function extractPageResultPayload(response: unknown): PageResult<unknown> | null {
const candidates: unknown[] = [];
const push = (x: unknown) => {
if (x !== undefined && x !== null) candidates.push(x);
};
push(response);
push(unwrapAxiosResponseBody(response));
for (let i = 0; i < candidates.length; i++) {
const x = candidates[i];
if (isPageResultPayload(x)) {
return x;
}
const normalized = normalizePageResultLike(x);
if (normalized) {
return normalized;
}
if (x && typeof x === "object" && !Array.isArray(x) && "data" in x) {
const inner = (x as Record<string, unknown>).data;
push(inner);
if (inner && typeof inner === "object" && !Array.isArray(inner) && "data" in inner) {
push((inner as Record<string, unknown>).data);
}
}
}
return null;
}
export interface BaseRequestParams extends PageQuery {
[key: string]: unknown;
}
@@ -246,104 +341,28 @@ export interface TableError {
details?: unknown;
}
function extractRecords<T>(obj: Record<string, unknown>, fields: string[]): T[] {
for (const field of fields) {
if (field in obj && Array.isArray(obj[field])) return obj[field] as T[];
}
return [];
}
function extractTotal(obj: Record<string, unknown>, records: unknown[], fields: string[]): number {
for (const field of fields) {
if (field in obj && typeof obj[field] === "number") return obj[field] as number;
}
return records.length;
}
function extractPagination(
obj: Record<string, unknown>,
data?: Record<string, unknown>
): Pick<ApiResponse<unknown>, "current" | "size"> | undefined {
const result: Partial<Pick<ApiResponse<unknown>, "current" | "size">> = {};
const sources = [obj, data ?? {}];
for (const src of sources) {
for (const field of tableConfig.currentFields) {
if (field in src && typeof src[field] === "number") {
result.current = src[field] as number;
break;
}
}
if (result.current !== undefined) break;
}
for (const src of sources) {
for (const field of tableConfig.sizeFields) {
if (field in src && typeof src[field] === "number") {
result.size = src[field] as number;
break;
}
}
if (result.size !== undefined) break;
}
if (result.current === undefined && result.size === undefined) return undefined;
return result;
}
export const defaultResponseAdapter = <T>(response: unknown): ApiResponse<T> => {
const recordFields = tableConfig.recordFields;
response = unwrapAxiosResponseBody(response);
if (!response) return { records: [], total: 0 };
if (Array.isArray(response)) return { records: response, total: response.length };
if (typeof response !== "object") {
console.warn(
"[tableUtils] 无法识别的响应格式,支持的格式包括: 数组、包含" +
recordFields.join("/") +
"字段的对象、嵌套data对象。当前格式:",
const pr = extractPageResultPayload(response);
if (!pr) {
console.error(
"[tableUtils] 分页列表响应必须符合全局 PageResult<T>items、total、page_no、page_size、has_next);或为 ApiResponse 且 data 为该结构。收到:",
response
);
return { records: [], total: 0 };
return { records: [], total: 0, current: 1, size: 10 };
}
const res = response as Record<string, unknown>;
let records: T[] = [];
let total = 0;
let pagination: Pick<ApiResponse<unknown>, "current" | "size"> | undefined;
records = extractRecords(res, recordFields);
total = extractTotal(res, records, tableConfig.totalFields);
pagination = extractPagination(res);
if (records.length === 0 && "data" in res && typeof res.data === "object") {
const data = res.data as Record<string, unknown>;
records = extractRecords(data, ["list", "records", "items"]);
total = extractTotal(data, records, tableConfig.totalFields);
pagination = extractPagination(res, data);
if (Array.isArray(res.data)) {
records = res.data as T[];
total = records.length;
}
}
if (!recordFields.some((field) => field in res) && records.length === 0) {
console.warn("[tableUtils] 无法识别的响应格式");
console.warn("支持的字段包括: " + recordFields.join("、"), response);
console.warn("扩展字段请到 utils/table/tableConfig 文件配置");
}
const result: ApiResponse<T> = { records, total };
if (pagination) Object.assign(result, pagination);
return result;
return {
records: pr.items as T[],
total: pr.total,
current: pr.page_no,
size: pr.page_size,
has_next: pr.has_next,
};
};
export const extractTableData = <T>(response: ApiResponse<T>): T[] => {
const data = response.records || response.data || [];
return Array.isArray(data) ? data : [];
const rows = response.records;
return Array.isArray(rows) ? rows : [];
};
export const updatePaginationFromResponse = <T>(
@@ -438,9 +457,7 @@ export const createErrorHandler = (
};
};
// -----------------------------
// Operation cell renderer
// -----------------------------
// --- 操作列渲染(表格单元格内按钮 + 溢出「更多」) ---
export const DEFAULT_MAX_INLINE_TABLE_OPERATIONS = 3;
@@ -512,7 +529,7 @@ export function renderTableOperationCell(
"span",
{ class: a.disabled ? "inline-flex opacity-40 pointer-events-none" : "inline-flex" },
[
h(FaButtonTable, {
h(ArtButtonTable, {
type: a.artType,
icon: iconForOperation(a),
iconColor: iconColorForOperation(a),
@@ -525,7 +542,7 @@ export function renderTableOperationCell(
if (overflow.length === 0) return h("div", { class: wrapperClass }, inlineNodes);
const moreDropdown = h(FaButtonMore, {
const moreDropdown = h(ArtButtonMore, {
list: overflow.map((a) => ({
key: a.key,
label: a.label,
+1 -1
View File
@@ -7,7 +7,7 @@
<div
v-for="item in upgradeLogList"
:key="item.version"
class="art-card-sm rounded-lg p-6 transition-shadow max-md:p-4"
class="fa-card-sm rounded-lg p-6 transition-shadow max-md:p-4"
>
<div class="flex-cb gap-3 mb-4 flex-wrap">
<span class="px-3 py-1 bg-theme/10 text-theme text-sm font-medium rounded-full">
+11 -11
View File
@@ -4,7 +4,7 @@
<div class="relative flex-b mt-2.5 max-md:block max-md:mt-1">
<!-- 左侧卡片 -->
<div class="w-112 mr-5 max-md:w-full max-md:mr-0">
<div class="art-card-sm relative p-9 pb-6 overflow-hidden text-center">
<div class="fa-card-sm relative p-9 pb-6 overflow-hidden text-center">
<img
class="absolute top-0 left-0 w-full h-50 object-cover"
src="@imgs/user/bg.webp"
@@ -58,19 +58,19 @@
<div class="relative z-10 w-75 mx-auto mt-7.5 text-left">
<div class="mt-2.5 flex items-start">
<ArtSvgIcon icon="ri:mail-line" class="text-g-700 shrink-0 mt-0.5" />
<FaSvgIcon icon="ri:mail-line" class="text-g-700 shrink-0 mt-0.5" />
<span class="ml-2 text-sm break-all">{{ infoFormState.email || "—" }}</span>
</div>
<div class="mt-2.5 flex items-start">
<ArtSvgIcon icon="ri:user-3-line" class="text-g-700 shrink-0 mt-0.5" />
<FaSvgIcon icon="ri:user-3-line" class="text-g-700 shrink-0 mt-0.5" />
<span class="ml-2 text-sm">{{ infoFormState.username || "—" }}</span>
</div>
<div class="mt-2.5 flex items-start">
<ArtSvgIcon icon="ri:map-pin-line" class="text-g-700 shrink-0 mt-0.5" />
<FaSvgIcon icon="ri:map-pin-line" class="text-g-700 shrink-0 mt-0.5" />
<span class="ml-2 text-sm">{{ infoFormState.dept?.name || "—" }}</span>
</div>
<div class="mt-2.5 flex items-start">
<ArtSvgIcon icon="ri:briefcase-line" class="text-g-700 shrink-0 mt-0.5" />
<FaSvgIcon icon="ri:briefcase-line" class="text-g-700 shrink-0 mt-0.5" />
<span class="ml-2 text-sm">
{{ infoFormState.positions?.map((p) => p.name).join("、") || "—" }}
</span>
@@ -100,7 +100,7 @@
destroy-on-close
@closed="onAvatarCropDialogClosed"
>
<ArtCutterImg
<FaCutterImg
v-if="avatarCropVisible && avatarCropSrc"
:key="avatarCropSrc"
:img-url="avatarCropSrc"
@@ -122,7 +122,7 @@
<!-- 右侧表单 -->
<div class="flex-1 overflow-hidden max-md:w-full max-md:mt-3.5">
<div class="art-card-sm">
<div class="fa-card-sm">
<h1 class="p-4 text-xl font-normal border-b border-g-300">基本设置</h1>
<ElForm
@@ -198,7 +198,7 @@
</ElForm>
</div>
<div class="art-card-sm my-5">
<div class="fa-card-sm my-5">
<h1 class="p-4 text-xl font-normal border-b border-g-300">更改密码</h1>
<ElForm
@@ -263,8 +263,8 @@ import { Camera } from "@element-plus/icons-vue";
import { ElMessage } from "element-plus";
import { useI18n } from "vue-i18n";
import { redirectToLogin } from "@utils/auth";
import ArtSvgIcon from "@/components/base/fa-svg-icon/index.vue";
import ArtCutterImg from "@/components/media/fa-cutter-img/index.vue";
import FaSvgIcon from "@/components/base/fa-svg-icon/index.vue";
import FaCutterImg from "@/components/media/fa-cutter-img/index.vue";
import { dataURLToFile } from "@utils/file/dataUrl";
defineOptions({ name: "UserProfile" });
@@ -609,7 +609,7 @@ onMounted(async () => {
bottom: 0;
z-index: 2;
:deep(.el-upload) {
::deep(.el-upload) {
display: inline-flex;
}
@@ -40,11 +40,11 @@
<h2 class="page-title">图表</h2>
<ElRow :gutter="20">
<ElCol :xs="24" :md="12" :lg="8" class="mb-5">
<div class="art-card">
<div class="fa-card">
<div class="pb-3.5">
<span class="text-base font-medium">柱状图单数据</span>
</div>
<ArtBarChart
<FaBarChart
:data="singleBarData"
:xAxisData="xAxisData"
:showLegend="true"
@@ -53,11 +53,11 @@
</div>
</ElCol>
<ElCol :xs="24" :md="12" :lg="8" class="mb-5">
<div class="art-card">
<div class="fa-card">
<div class="pb-3.5">
<span class="text-base font-medium">柱状图多组数据</span>
</div>
<ArtBarChart
<FaBarChart
:data="multiBarData"
:xAxisData="xAxisData"
:showLegend="true"
@@ -66,11 +66,11 @@
</div>
</ElCol>
<ElCol :xs="24" :md="12" :lg="8" class="mb-5">
<div class="art-card">
<div class="fa-card">
<div class="pb-3.5">
<span class="text-base font-medium">柱状图堆叠</span>
</div>
<ArtBarChart
<FaBarChart
:data="stackBarData"
:xAxisData="stackXAxisData"
:showLegend="true"
@@ -80,11 +80,11 @@
</div>
</ElCol>
<ElCol :xs="24" :md="12" :lg="8" class="mb-5">
<div class="art-card">
<div class="fa-card">
<div class="pb-3.5">
<span class="text-base font-medium">折线图</span>
</div>
<ArtLineChart
<FaLineChart
:data="[58, 15, 82, 35, 120, 62, 45]"
:xAxisData="['一月', '二月', '三月', '四月', '五月', '六月', '七月']"
symbol="none"
@@ -93,11 +93,11 @@
</div>
</ElCol>
<ElCol :xs="24" :md="12" :lg="8" class="mb-5">
<div class="art-card">
<div class="fa-card">
<div class="pb-3.5">
<span class="text-base font-medium">折线图渐变背景</span>
</div>
<ArtLineChart
<FaLineChart
:data="[28, 45, 82, 35, 100, 32, 55]"
:xAxisData="['一月', '二月', '三月', '四月', '五月', '六月', '七月']"
:showAreaColor="true"
@@ -105,11 +105,11 @@
</div>
</ElCol>
<ElCol :xs="24" :md="12" :lg="8" class="mb-5">
<div class="art-card">
<div class="fa-card">
<div class="pb-3.5">
<span class="text-base font-medium">折线图多组数据</span>
</div>
<ArtLineChart
<FaLineChart
:data="multiLineData"
:xAxisData="['1月', '2月', '3月', '4月', '5月', '6月']"
:showLegend="true"
@@ -118,23 +118,23 @@
</ElCol>
<ElCol :xs="24" :md="12" :lg="8" class="mb-5">
<div class="art-card">
<div class="fa-card">
<div class="pb-3.5">
<span class="text-base font-medium">柱状图水平</span>
</div>
<ArtHBarChart
<FaHBarChart
:data="[50, 80, 120, 90, 60]"
:xAxisData="['产品A', '产品B', '产品C', '产品D', '产品E']"
/>
</div>
</ElCol>
<ElCol :xs="24" :md="12" :lg="8" class="mb-5">
<div class="art-card">
<div class="fa-card">
<div class="pb-3.5">
<span class="text-base font-medium">柱状图水平</span>
</div>
<ArtHBarChart
<FaHBarChart
:data="[
{ name: '系列1', data: [10, 20, 30] },
{ name: '系列2', data: [15, 25, 35] },
@@ -146,11 +146,11 @@
</div>
</ElCol>
<ElCol :xs="24" :md="12" :lg="8" class="mb-5">
<div class="art-card">
<div class="fa-card">
<div class="pb-3.5">
<span class="text-base font-medium">柱状图水平堆叠</span>
</div>
<ArtHBarChart
<FaHBarChart
:data="[
{ name: '系列1', data: [10, 20, 30] },
{ name: '系列2', data: [15, 25, 35] },
@@ -164,11 +164,11 @@
</ElCol>
<ElCol :xs="24" :md="12" :lg="8" class="mb-5">
<div class="art-card">
<div class="fa-card">
<div class="pb-3.5">
<span class="text-base font-medium">环形图</span>
</div>
<ArtRingChart
<FaRingChart
:data="[
{ value: 35, name: '分类A' },
{ value: 28, name: '分类B' },
@@ -181,11 +181,11 @@
</ElCol>
<ElCol :xs="24" :md="12" :lg="8" class="mb-5">
<div class="art-card">
<div class="fa-card">
<div class="pb-3.5">
<span class="text-base font-medium">环形图</span>
</div>
<ArtRingChart
<FaRingChart
:data="[
{ value: 35, name: '分类A' },
{ value: 28, name: '分类B' },
@@ -203,11 +203,11 @@
</div>
</ElCol>
<ElCol :xs="24" :md="12" :lg="8" class="mb-5">
<div class="art-card">
<div class="fa-card">
<div class="pb-3.5">
<span class="text-base font-medium">饼图</span>
</div>
<ArtRingChart
<FaRingChart
:data="[
{ value: 30, name: '分类A' },
{ value: 25, name: '分类B' },
@@ -220,11 +220,11 @@
</div>
</ElCol>
<ElCol :xs="24" :md="12" :lg="8" class="mb-5">
<div class="art-card">
<div class="fa-card">
<div class="pb-3.5">
<span class="text-base font-medium">散点图</span>
</div>
<ArtScatterChart
<FaScatterChart
:data="[
{ value: [1, 3] },
{ value: [2, 4] },
@@ -244,11 +244,11 @@
</ElCol>
<ElCol :xs="24" :md="12" :lg="8" class="mb-5">
<div class="art-card">
<div class="fa-card">
<div class="pb-3.5">
<span class="text-base font-medium">雷达图</span>
</div>
<ArtRadarChart
<FaRadarChart
:indicator="[
{ name: '销售', max: 100 },
{ name: '管理', max: 100 },
@@ -270,11 +270,11 @@
</div>
</ElCol>
<ElCol :xs="24" :md="12" :lg="8" class="mb-5">
<div class="art-card">
<div class="fa-card">
<div class="pb-3.5">
<span class="text-base font-medium">k线图</span>
</div>
<ArtKLineChart
<FaKLineChart
:data="[
{ time: '2024-01-01', open: 20, close: 23, high: 25, low: 18 },
{ time: '2024-01-02', open: 23, close: 21, high: 24, low: 20 },
@@ -287,11 +287,11 @@
</div>
</ElCol>
<ElCol :xs="24" :md="12" :lg="8" class="mb-5">
<div class="art-card">
<div class="fa-card">
<div class="pb-3.5">
<span class="text-base font-medium">双向堆叠柱状图</span>
</div>
<ArtDualBarCompareChart
<FaDualBarCompareChart
:positiveData="[50, 28, 80, 65, 68, 70, 60, 55]"
:negativeData="[50, 28, 40, 45, 38, 50, 42, 48]"
:xAxisData="[
@@ -373,7 +373,7 @@ const stackXAxisData = ref(["产品A", "产品B", "产品C", "产品D", "产品E
@apply my-5 text-xl font-medium first:mt-0;
}
.art-card {
.fa-card {
@apply p-5;
}
</style>
@@ -1,11 +1,11 @@
<template>
<div class="art-card h-100 p-5 mb-5 max-sm:mb-4">
<div class="art-card-header">
<div class="fa-card h-100 p-5 mb-5 max-sm:mb-4">
<div class="fa-card-header">
<div class="title">
<h4>客户满意度</h4>
</div>
</div>
<ArtLineChart
<FaLineChart
height="calc(100% - 30px)"
:data="chartData"
:xAxisData="xAxisData"
@@ -1,12 +1,12 @@
<template>
<div class="art-card h-82 p-5 mb-5 max-sm:mb-4">
<div class="art-card-header">
<div class="fa-card h-82 p-5 mb-5 max-sm:mb-4">
<div class="fa-card-header">
<div class="title">
<h4>全国销售分布</h4>
</div>
</div>
<ArtRingChart
<FaRingChart
:data="[
{ value: 30, name: '北京' },
{ value: 25, name: '上海' },
@@ -1,12 +1,12 @@
<template>
<div class="art-card h-100 p-5 mb-5 max-sm:mb-4">
<div class="art-card-header">
<div class="fa-card h-100 p-5 mb-5 max-sm:mb-4">
<div class="fa-card-header">
<div class="title">
<h4>目标与实际</h4>
</div>
</div>
<ArtBarChart
<FaBarChart
class="p-5"
height="12rem"
:data="revenueData"
@@ -19,7 +19,7 @@
<div v-for="item in totalItems" :key="item.label" class="flex-c mb-5 last:mb-0">
<div class="flex-c justify-start w-3/5 text-sm">
<div class="w-10 h-10 mr-3 text-lg rounded-md flex-cc" :class="item.iconClass">
<ArtSvgIcon :icon="item.icon" />
<FaSvgIcon :icon="item.icon" />
</div>
<div class="flex flex-col items-start">
@@ -1,12 +1,12 @@
<template>
<div class="art-card h-82 p-5 mb-5 overflow-hidden max-lg:h-auto max-sm:mb-4">
<div class="art-card-header pr-0">
<div class="fa-card h-82 p-5 mb-5 overflow-hidden max-lg:h-auto max-sm:mb-4">
<div class="fa-card-header pr-0">
<div class="title">
<h4>今日销售</h4>
<p>销售总结</p>
</div>
<div class="flex-cc h-7.5 min-w-17 border border-g-300 rounded-lg text-g-500 c-p">
<ArtSvgIcon icon="ri:arrow-up-line" class="text-base mr-1.5" />
<FaSvgIcon icon="ri:arrow-up-line" class="text-base mr-1.5" />
<span class="text-xs">导出</span>
</div>
</div>
@@ -18,11 +18,11 @@
class="flex px-5 flex-col justify-center h-55 border border-g-300/85 rounded-xl max-lg:mb-4 max-sm:flex-row max-sm:justify-between max-sm:items-center max-sm:h-40"
>
<div class="size-12 rounded-lg flex-cc bg-theme/10">
<ArtSvgIcon :icon="item.icon" class="text-xl text-theme" />
<FaSvgIcon :icon="item.icon" class="text-xl text-theme" />
</div>
<div class="max-sm:ml-4 mt-3.5 max-sm:mt-0 max-sm:text-end">
<ArtCountTo class="text-2xl font-medium" :target="item.value" :duration="1500" />
<FaCountTo class="text-2xl font-medium" :target="item.value" :duration="1500" />
<p class="mt-2 text-base text-g-600 max-sm:mt-1">{{ item.label }}</p>
<small class="text-g-500 mt-1 max-sm:mt-0.5">
较昨天
@@ -1,6 +1,6 @@
<template>
<div class="art-card h-82 p-5 mb-5 overflow-hidden max-sm:mb-4">
<div class="art-card-header">
<div class="fa-card h-82 p-5 mb-5 overflow-hidden max-sm:mb-4">
<div class="fa-card-header">
<div class="title">
<h4>热门产品</h4>
</div>
@@ -1,11 +1,11 @@
<template>
<div class="art-card h-100 p-5 mb-5 max-sm:mb-4">
<div class="art-card-header">
<div class="fa-card h-100 p-5 mb-5 max-sm:mb-4">
<div class="fa-card-header">
<div class="title">
<h4>总收入</h4>
</div>
</div>
<ArtBarChart
<FaBarChart
height="calc(100% - 30px)"
:data="revenueData"
:xAxisData="weekDays"
@@ -1,11 +1,11 @@
<template>
<div class="art-card h-82 p-5 mb-5 max-sm:mb-4">
<div class="art-card-header">
<div class="fa-card h-82 p-5 mb-5 max-sm:mb-4">
<div class="fa-card-header">
<div class="title">
<h4>访客洞察</h4>
</div>
</div>
<ArtLineChart
<FaLineChart
height="calc(100% - 30px)"
:data="chartData"
:xAxisData="xAxisData"
@@ -1,12 +1,12 @@
<template>
<div class="art-card h-82 p-5 mb-5 max-sm:mb-4">
<div class="art-card-header">
<div class="fa-card h-82 p-5 mb-5 max-sm:mb-4">
<div class="fa-card-header">
<div class="title">
<h4>业务量与服务水平</h4>
</div>
</div>
<ArtBarChart
<FaBarChart
class="mt-6"
height="14.3rem"
:data="volumeServiceData"
@@ -9,9 +9,7 @@
destroy-on-close
class="article-comment-wall-drawer"
>
<p class="mt-0 mb-8 text-g-600">
每一份留言都记录了您的想法也为我们提供了珍贵的回忆
</p>
<p class="mt-0 mb-8 text-g-600">每一份留言都记录了您的想法也为我们提供了珍贵的回忆</p>
<ul
class="grid grid-cols-5 gap-5 max-2xl:grid-cols-4 max-xl:grid-cols-3 max-lg:grid-cols-2 max-sm:grid-cols-1 mb-5 list-none p-0 m-0"
@@ -28,11 +26,11 @@
<div class="absolute bottom-4 left-0 px-4 flex-cb w-full">
<div class="flex-c">
<div class="flex-c mr-5 text-xs text-g-600">
<ArtSvgIcon icon="ri:heart-line" class="mr-1 text-base" />
<FaSvgIcon icon="ri:heart-line" class="mr-1 text-base" />
<span>{{ item.collection }}</span>
</div>
<div class="flex-c mr-5 text-xs text-g-600">
<ArtSvgIcon icon="ri:message-3-line" class="mr-1 text-base" />
<FaSvgIcon icon="ri:message-3-line" class="mr-1 text-base" />
<span>{{ item.comment }}</span>
</div>
</div>
@@ -55,17 +53,20 @@
</template>
<template #default>
<div class="drawer-default">
<div class="relative p-4 aspect-16/12 rounded-md" :style="{ background: clickItem.color }">
<div
class="relative p-4 aspect-16/12 rounded-md"
:style="{ background: clickItem.color }"
>
<p class="text-g-500 text-sm">{{ clickItem.date }}</p>
<p class="mt-4 text-sm text-gray-800">{{ clickItem.content }}</p>
<div class="absolute bottom-4 left-0 px-4 flex-cb w-full">
<div class="flex-c">
<div class="flex-c mr-5 text-xs text-g-600">
<ArtSvgIcon icon="ri:heart-line" class="mr-1 text-base" />
<FaSvgIcon icon="ri:heart-line" class="mr-1 text-base" />
<span>{{ clickItem.collection }}</span>
</div>
<div class="flex-c mr-5 text-xs text-g-600">
<ArtSvgIcon icon="ri:message-3-line" class="mr-1 text-base" />
<FaSvgIcon icon="ri:message-3-line" class="mr-1 text-base" />
<span>{{ clickItem.comment }}</span>
</div>
</div>

Some files were not shown because too many files have changed in this diff Show More